diff --git a/packages/back-end/generated/spec.yaml b/packages/back-end/generated/spec.yaml index 4a2990458a7..f15c8697dd2 100644 --- a/packages/back-end/generated/spec.yaml +++ b/packages/back-end/generated/spec.yaml @@ -60,6 +60,9 @@ tags: - name: projects x-displayName: Projects description: Projects are used to organize your feature flags and experiments + - name: environments + x-displayName: Environments + description: 'GrowthBook comes with one environment by default (production), but you can add as many as you need. When used with feature flags, you can enable/disable feature flags on a per-environment basis.' - name: features x-displayName: Feature Flags description: Control your feature flags programatically @@ -105,6 +108,9 @@ tags: - name: Dimension_model x-displayName: Dimension description: + - name: Environment_model + x-displayName: Environment + description: - name: Experiment_model x-displayName: Experiment description: @@ -2437,6 +2443,146 @@ paths: properties: organization: $ref: '#/components/schemas/Organization' + /environments: + get: + summary: Get the organization's environments + tags: + - environments + operationId: listEnvironments + x-codeSamples: + - lang: cURL + source: | + curl https://api.growthbook.io/api/v1/environments \ + -u secret_abc123DEF456: + responses: + '200': + content: + application/json: + schema: + allOf: + - type: object + required: + - environments + properties: + environments: + type: array + items: + $ref: '#/components/schemas/Environment' + post: + tags: + - environments + summary: Create a new environment + operationId: postEnvironment + x-codeSamples: + - lang: cURL + source: | + curl -X POST https://api.growthbook.io/api/v1/environments \ + -d '{"id": "new-env", "description": "My new environment" }' \ + -u secret_abc123DEF456: + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + description: The ID of the new environment + description: + type: string + description: The description of the new environment + toggleOnList: + type: bool + description: Show toggle on feature list + defaultState: + type: bool + description: Default state for new features + projects: + type: array + items: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + required: + - environment + properties: + environment: + $ref: '#/components/schemas/Environment' + '/environments/${id}': + put: + parameters: + - $ref: '#/components/parameters/id' + tags: + - environments + summary: Update an environment + operationId: putEnvironment + x-codeSamples: + - lang: cURL + source: | + curl -X PUT https://api.growthbook.io/api/v1/environments/env-id \ + -d '{ "description": "My updated environment" }' \ + -u secret_abc123DEF456: + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: string + description: The description of the new environment + toggleOnList: + type: boolean + description: Show toggle on feature list + defaultState: + type: boolean + description: Default state for new features + projects: + type: array + items: + type: string + responses: + '200': + content: + application/json: + schema: + type: object + required: + - environment + properties: + environment: + $ref: '#/components/schemas/Environment' + delete: + summary: Deletes a single environment + parameters: + - $ref: '#/components/parameters/id' + tags: + - environments + operationId: deleteEnvironment + x-codeSamples: + - lang: cURL + source: | + curl -X DELETE https://api.growthbook.io/api/v1/enviromnents/env-id \ + -u secret_abc123DEF456: + responses: + '200': + content: + application/json: + schema: + type: object + required: + - deletedId + properties: + deletedId: + type: string /fact-tables: get: summary: Get all fact tables @@ -3895,6 +4041,27 @@ components: properties: statsEngine: type: string + Environment: + type: object + required: + - id + - description + - toggleOnList + - defaultState + - projects + properties: + id: + type: string + description: + type: string + toggleOnList: + type: boolean + defaultState: + type: boolean + projects: + type: array + items: + type: string Segment: type: object required: @@ -5337,6 +5504,7 @@ x-tagGroups: - name: Endpoints tags: - projects + - environments - features - data-sources - metrics @@ -5354,6 +5522,7 @@ x-tagGroups: tags: - DataSource_model - Dimension_model + - Environment_model - Experiment_model - ExperimentAnalysisSettings_model - ExperimentMetric_model diff --git a/packages/back-end/package.json b/packages/back-end/package.json index c673a765784..6b96e7c3c07 100644 --- a/packages/back-end/package.json +++ b/packages/back-end/package.json @@ -27,8 +27,8 @@ "generate-api-types": "yarn generate-api-models && swagger-cli bundle -t yaml src/api/openapi/openapi.tmp.yaml -o generated/spec.yaml && node src/scripts/generate-openapi.mjs" }, "dependencies": { - "@aws-sdk/client-sts": "^3.567.0", "@aws-sdk/client-athena": "^3.564.0", + "@aws-sdk/client-sts": "^3.567.0", "@clickhouse/client": "^1.0.1", "@databricks/sql": "^1.8.1", "@dqbd/tiktoken": "^1.0.7", @@ -141,7 +141,9 @@ "delay-cli": "^2.0.0", "jest": "^27.1.1", "minimist": "^1.2.8", + "mongodb-memory-server": "^9.2.0", "rimraf": "^3.0.2", + "supertest": "^7.0.0", "typescript": "5.3.3" }, "optionalDependencies": { diff --git a/packages/back-end/src/api/api.router.ts b/packages/back-end/src/api/api.router.ts index 87f046f3479..87cf883ed75 100644 --- a/packages/back-end/src/api/api.router.ts +++ b/packages/back-end/src/api/api.router.ts @@ -11,6 +11,7 @@ import experimentsRouter from "./experiments/experiments.router"; import metricsRouter from "./metrics/metrics.router"; import segmentsRouter from "./segments/segments.router"; import projectsRouter from "./projects/projects.router"; +import environmentsRouter from "./environments/environments.router"; import savedGroupsRouter from "./saved-groups/saved-groups.router"; import sdkConnectionsRouter from "./sdk-connections/sdk-connections.router"; import sdkPayloadRouter from "./sdk-payload/sdk-payload.router"; @@ -82,6 +83,7 @@ router.use("/metrics", metricsRouter); router.use("/segments", segmentsRouter); router.use("/dimensions", dimensionsRouter); router.use("/projects", projectsRouter); +router.use("/environments", environmentsRouter); router.use("/sdk-connections", sdkConnectionsRouter); router.use("/data-sources", dataSourcesRouter); router.use("/visual-changesets", visualChangesetsRouter); diff --git a/packages/back-end/src/api/environments/deleteEnvironment.ts b/packages/back-end/src/api/environments/deleteEnvironment.ts new file mode 100644 index 00000000000..50bd479f072 --- /dev/null +++ b/packages/back-end/src/api/environments/deleteEnvironment.ts @@ -0,0 +1,46 @@ +import { DeleteEnvironmentResponse } from "../../../types/openapi"; +import { createApiRequestHandler } from "../../util/handler"; +import { deleteEnvironmentValidator } from "../../validators/openapi"; +import { updateOrganization } from "../../models/OrganizationModel"; +import { OrganizationInterface } from "../../../types/organization"; +import { auditDetailsDelete } from "../../services/audit"; + +export const deleteEnvironment = createApiRequestHandler( + deleteEnvironmentValidator +)( + async (req): Promise => { + const id = req.params.id; + const org = req.context.org; + const environments = org.settings?.environments || []; + + const environment = environments.find((env) => env.id === id); + if (!environment) { + throw Error(`Environment ${id} does not exists!`); + } + + if (!req.context.permissions.canDeleteEnvironment(environment)) + req.context.permissions.throwPermissionError(); + + const updates: Partial = { + settings: { + ...org.settings, + environments: [...environments.filter((env) => env.id !== id)], + }, + }; + + await updateOrganization(org.id, updates); + + await req.audit({ + event: "environment.delete", + entity: { + object: "environment", + id: environment.id, + }, + details: auditDetailsDelete(environment), + }); + + return { + deletedId: id, + }; + } +); diff --git a/packages/back-end/src/api/environments/environments.router.ts b/packages/back-end/src/api/environments/environments.router.ts new file mode 100644 index 00000000000..a44ef8c4a11 --- /dev/null +++ b/packages/back-end/src/api/environments/environments.router.ts @@ -0,0 +1,14 @@ +import { Router } from "express"; +import { listEnvironments } from "./listEnvironments"; +import { putEnvironment } from "./putEnvironment"; +import { postEnvironment } from "./postEnvironment"; +import { deleteEnvironment } from "./deleteEnvironment"; + +const router = Router(); + +router.get("/", listEnvironments); +router.post("/", postEnvironment); +router.put("/:id", putEnvironment); +router.delete("/:id", deleteEnvironment); + +export default router; diff --git a/packages/back-end/src/api/environments/listEnvironments.ts b/packages/back-end/src/api/environments/listEnvironments.ts new file mode 100644 index 00000000000..de07ea61b20 --- /dev/null +++ b/packages/back-end/src/api/environments/listEnvironments.ts @@ -0,0 +1,33 @@ +import { ListEnvironmentsResponse } from "../../../types/openapi"; +import { createApiRequestHandler } from "../../util/handler"; +import { listEnvironmentsValidator } from "../../validators/openapi"; + +export const listEnvironments = createApiRequestHandler( + listEnvironmentsValidator +)( + async (req): Promise => { + const environments = ( + req.context.org.settings?.environments || [] + ).filter((environment) => + req.context.permissions.canReadMultiProjectResource(environment.projects) + ); + + return { + environments: environments.map( + ({ + id, + description = "", + toggleOnList = false, + defaultState = false, + projects = [], + }) => ({ + id, + projects, + description, + defaultState, + toggleOnList, + }) + ), + }; + } +); diff --git a/packages/back-end/src/api/environments/postEnvironment.ts b/packages/back-end/src/api/environments/postEnvironment.ts new file mode 100644 index 00000000000..042cde7d259 --- /dev/null +++ b/packages/back-end/src/api/environments/postEnvironment.ts @@ -0,0 +1,46 @@ +import { PostEnvironmentResponse } from "../../../types/openapi"; +import { createApiRequestHandler } from "../../util/handler"; +import { postEnvironmentValidator } from "../../validators/openapi"; +import { updateOrganization } from "../../models/OrganizationModel"; +import { OrganizationInterface } from "../../../types/organization"; +import { auditDetailsCreate } from "../../services/audit"; +import { validatePayload } from "./validations"; + +export const postEnvironment = createApiRequestHandler( + postEnvironmentValidator +)( + async (req): Promise => { + const environment = await validatePayload(req.context, req.body); + + const org = req.context.org; + + if (org.settings?.environments?.some((env) => env.id === environment.id)) { + throw Error(`Environment ${environment.id} already exists!`); + } + + if (!req.context.permissions.canCreateOrUpdateEnvironment(environment)) + req.context.permissions.throwPermissionError(); + + const updates: Partial = { + settings: { + ...org.settings, + environments: [...(org.settings?.environments || []), environment], + }, + }; + + await updateOrganization(org.id, updates); + + await req.audit({ + event: "environment.create", + entity: { + object: "environment", + id: environment.id, + }, + details: auditDetailsCreate(environment), + }); + + return { + environment, + }; + } +); diff --git a/packages/back-end/src/api/environments/putEnvironment.ts b/packages/back-end/src/api/environments/putEnvironment.ts new file mode 100644 index 00000000000..2868e70080d --- /dev/null +++ b/packages/back-end/src/api/environments/putEnvironment.ts @@ -0,0 +1,54 @@ +import { PutEnvironmentResponse } from "../../../types/openapi"; +import { createApiRequestHandler } from "../../util/handler"; +import { putEnvironmentValidator } from "../../validators/openapi"; +import { updateOrganization } from "../../models/OrganizationModel"; +import { OrganizationInterface } from "../../../types/organization"; +import { auditDetailsUpdate } from "../../services/audit"; +import { validatePayload } from "./validations"; + +export const putEnvironment = createApiRequestHandler(putEnvironmentValidator)( + async (req): Promise => { + const id = req.params.id; + const org = req.context.org; + const environments = org.settings?.environments || []; + + const environment = environments.find((env) => env.id === id); + if (!environment) { + throw Error(`Environment ${id} does not exists!`); + } + + const updatedEnvironment = await validatePayload(req.context, { + ...environment, + ...req.body, + }); + + if ( + !req.context.permissions.canCreateOrUpdateEnvironment(updatedEnvironment) + ) + req.context.permissions.throwPermissionError(); + + const updates: Partial = { + settings: { + ...org.settings, + environments: environments.map((env) => + env.id === id ? updatedEnvironment : env + ), + }, + }; + + await updateOrganization(org.id, updates); + + await req.audit({ + event: "environment.update", + entity: { + object: "environment", + id: environment.id, + }, + details: auditDetailsUpdate(environment, updatedEnvironment), + }); + + return { + environment: updatedEnvironment, + }; + } +); diff --git a/packages/back-end/src/api/environments/validations.ts b/packages/back-end/src/api/environments/validations.ts new file mode 100644 index 00000000000..3e0e420950d --- /dev/null +++ b/packages/back-end/src/api/environments/validations.ts @@ -0,0 +1,35 @@ +import { findAllProjectsByOrganization } from "../../models/ProjectModel"; +import { ApiReqContext } from "../../../types/api"; + +export const validatePayload = async ( + context: ApiReqContext, + { + id, + description = "", + projects = [], + toggleOnList = false, + defaultState = false, + }: { + id: string; + description?: string; + projects?: string[]; + toggleOnList?: boolean; + defaultState?: boolean; + } +) => { + if (id === "") throw Error("Environment ID cannot empty!"); + + if (projects.length) { + const allProjects = await findAllProjectsByOrganization(context); + const nonexistentProjects = projects.filter( + (p) => !allProjects.some(({ id }) => p === id) + ); + + if (nonexistentProjects.length) + throw new Error( + `The following projects do not exist: ${nonexistentProjects.join(", ")}` + ); + } + + return { id, projects, description, toggleOnList, defaultState }; +}; diff --git a/packages/back-end/src/api/openapi/openapi.yaml b/packages/back-end/src/api/openapi/openapi.yaml index fd2ef186285..0be63a1b108 100644 --- a/packages/back-end/src/api/openapi/openapi.yaml +++ b/packages/back-end/src/api/openapi/openapi.yaml @@ -60,6 +60,12 @@ tags: - name: projects x-displayName: Projects description: Projects are used to organize your feature flags and experiments + - name: environments + x-displayName: Environments + description: GrowthBook comes with one environment by default (production), + but you can add as many as you need. When used with feature + flags, you can enable/disable feature flags on a per-environment + basis. - name: features x-displayName: Feature Flags description: Control your feature flags programatically @@ -190,6 +196,16 @@ paths: /organizations/{id}: put: $ref: "./paths/putOrganization.yaml" + /environments: + get: + $ref: "./paths/listEnvironments.yaml" + post: + $ref: "./paths/postEnvironment.yaml" + /environments/${id}: + put: + $ref: "./paths/putEnvironment.yaml" + delete: + $ref: "./paths/deleteEnvironment.yaml" /fact-tables: get: $ref: "./paths/listFactTables.yaml" diff --git a/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml b/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml new file mode 100644 index 00000000000..cd1212df4d6 --- /dev/null +++ b/packages/back-end/src/api/openapi/paths/deleteEnvironment.yaml @@ -0,0 +1,22 @@ +summary: Deletes a single environment +parameters: + - $ref: "../parameters.yaml#/id" +tags: + - environments +operationId: deleteEnvironment +x-codeSamples: + - lang: "cURL" + source: | + curl -X DELETE https://api.growthbook.io/api/v1/enviromnents/env-id \ + -u secret_abc123DEF456: +responses: + "200": + content: + application/json: + schema: + type: object + required: + - deletedId + properties: + deletedId: + type: string diff --git a/packages/back-end/src/api/openapi/paths/listEnvironments.yaml b/packages/back-end/src/api/openapi/paths/listEnvironments.yaml new file mode 100644 index 00000000000..1e509a06579 --- /dev/null +++ b/packages/back-end/src/api/openapi/paths/listEnvironments.yaml @@ -0,0 +1,23 @@ +summary: Get the organization's environments +tags: + - environments +operationId: listEnvironments +x-codeSamples: + - lang: 'cURL' + source: | + curl https://api.growthbook.io/api/v1/environments \ + -u secret_abc123DEF456: +responses: + "200": + content: + application/json: + schema: + allOf: + - type: object + required: + - environments + properties: + environments: + type: array + items: + $ref: "../schemas/Environment.yaml" diff --git a/packages/back-end/src/api/openapi/paths/postEnvironment.yaml b/packages/back-end/src/api/openapi/paths/postEnvironment.yaml new file mode 100644 index 00000000000..d7181b551dc --- /dev/null +++ b/packages/back-end/src/api/openapi/paths/postEnvironment.yaml @@ -0,0 +1,46 @@ +tags: + - environments +summary: Create a new environment +operationId: postEnvironment +x-codeSamples: + - lang: "cURL" + source: | + curl -X POST https://api.growthbook.io/api/v1/environments \ + -d '{"id": "new-env", "description": "My new environment" }' \ + -u secret_abc123DEF456: +requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - id + properties: + id: + type: string + description: The ID of the new environment + description: + type: string + description: The description of the new environment + toggleOnList: + type: bool + description: Show toggle on feature list + defaultState: + type: bool + description: Default state for new features + projects: + type: array + items: + type: string +responses: + "200": + content: + application/json: + schema: + type: object + required: + - environment + properties: + environment: + $ref: "../schemas/Environment.yaml" diff --git a/packages/back-end/src/api/openapi/paths/putEnvironment.yaml b/packages/back-end/src/api/openapi/paths/putEnvironment.yaml new file mode 100644 index 00000000000..aa504279259 --- /dev/null +++ b/packages/back-end/src/api/openapi/paths/putEnvironment.yaml @@ -0,0 +1,43 @@ +parameters: + - $ref: "../parameters.yaml#/id" +tags: + - environments +summary: Update an environment +operationId: putEnvironment +x-codeSamples: + - lang: "cURL" + source: | + curl -X PUT https://api.growthbook.io/api/v1/environments/env-id \ + -d '{ "description": "My updated environment" }' \ + -u secret_abc123DEF456: +requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: string + description: The description of the new environment + toggleOnList: + type: boolean + description: Show toggle on feature list + defaultState: + type: boolean + description: Default state for new features + projects: + type: array + items: + type: string +responses: + "200": + content: + application/json: + schema: + type: object + required: + - environment + properties: + environment: + $ref: "../schemas/Environment.yaml" diff --git a/packages/back-end/src/api/openapi/schemas/Environment.yaml b/packages/back-end/src/api/openapi/schemas/Environment.yaml new file mode 100644 index 00000000000..43c73c647ed --- /dev/null +++ b/packages/back-end/src/api/openapi/schemas/Environment.yaml @@ -0,0 +1,20 @@ +type: object +required: + - id + - description + - toggleOnList + - defaultState + - projects +properties: + id: + type: string + description: + type: string + toggleOnList: + type: boolean + defaultState: + type: boolean + projects: + type: array + items: + type: string diff --git a/packages/back-end/src/api/openapi/schemas/_index.yaml b/packages/back-end/src/api/openapi/schemas/_index.yaml index 59eee9904f2..1b2d4f678f6 100644 --- a/packages/back-end/src/api/openapi/schemas/_index.yaml +++ b/packages/back-end/src/api/openapi/schemas/_index.yaml @@ -6,6 +6,8 @@ Metric: $ref: "./Metric.yaml" Project: $ref: "./Project.yaml" +Environment: + $ref: "./Environment.yaml" Segment: $ref: "./Segment.yaml" Feature: diff --git a/packages/back-end/src/app.ts b/packages/back-end/src/app.ts index e585f64fc9e..b485567c8bc 100644 --- a/packages/back-end/src/app.ts +++ b/packages/back-end/src/app.ts @@ -120,7 +120,7 @@ if (SENTRY_DSN) { ); } -if (!process.env.NO_INIT) { +if (!process.env.NO_INIT && process.env.NODE_ENV !== "test") { init(); } diff --git a/packages/back-end/src/models/AuditModel.ts b/packages/back-end/src/models/AuditModel.ts index 2a555ed28f0..e3d28997701 100644 --- a/packages/back-end/src/models/AuditModel.ts +++ b/packages/back-end/src/models/AuditModel.ts @@ -46,7 +46,10 @@ const AuditModel = mongoose.model("Audit", auditSchema); * @param doc */ const toInterface = (doc: AuditDocument): AuditInterface => { - return omit(doc.toJSON(), ["__v", "_id"]); + return (omit(doc.toJSON(), [ + "__v", + "_id", + ]) as unknown) as AuditInterface; }; export async function insertAudit( diff --git a/packages/back-end/src/models/BaseModel.ts b/packages/back-end/src/models/BaseModel.ts index 000057b4bc4..1f81f4b926c 100644 --- a/packages/back-end/src/models/BaseModel.ts +++ b/packages/back-end/src/models/BaseModel.ts @@ -10,8 +10,8 @@ import { ApiReqContext } from "../../types/api"; import { ReqContext } from "../../types/organization"; import { CreateProps, UpdateProps } from "../../types/models"; import { logger } from "../util/logger"; -import { EventType } from "../../types/audit"; -import { EntityType } from "../types/Audit"; +import { EntityType, EventTypes, EventType } from "../types/Audit"; +import { AuditInterfaceTemplate } from "../../types/audit"; import { auditDetailsCreate, auditDetailsDelete, @@ -31,16 +31,18 @@ export const baseSchema = z export type BaseSchema = typeof baseSchema; -export interface ModelConfig { +type AuditLogConfig = { + entity: Entity; + createEvent: EventTypes; + updateEvent: EventTypes; + deleteEvent: EventTypes; +}; + +export interface ModelConfig { schema: T; collectionName: string; idPrefix?: string; - auditLog: { - entity: EntityType; - createEvent: EventType; - updateEvent: EventType; - deleteEvent: EventType; - }; + auditLog: AuditLogConfig; projectScoping: "none" | "single" | "multiple"; globallyUniqueIds?: boolean; skipDateUpdatedFields?: (keyof z.infer)[]; @@ -59,7 +61,7 @@ export interface ModelConfig { // We only need to add indexes once at server start-up const indexesAdded: Set = new Set(); -export abstract class BaseModel { +export abstract class BaseModel { protected context: Context; public constructor(context: Context) { this.context = context; @@ -70,8 +72,8 @@ export abstract class BaseModel { /*************** * Required methods that MUST be overridden by subclasses ***************/ - protected config: ModelConfig; - protected abstract getConfig(): ModelConfig; + protected config: ModelConfig; + protected abstract getConfig(): ModelConfig; protected abstract canRead(doc: z.infer): boolean; protected abstract canCreate(doc: z.infer): boolean; protected abstract canUpdate( @@ -312,7 +314,7 @@ export abstract class BaseModel { }, event: this.config.auditLog.createEvent, details: auditDetailsCreate(doc), - }); + } as AuditInterfaceTemplate); } catch (e) { this.context.logger.error( e, @@ -424,7 +426,7 @@ export abstract class BaseModel { }, event: auditEvent, details: auditDetailsUpdate(doc, newDoc), - }); + } as AuditInterfaceTemplate); } catch (e) { this.context.logger.error( e, @@ -462,7 +464,7 @@ export abstract class BaseModel { }, event: this.config.auditLog.deleteEvent, details: auditDetailsDelete(doc), - }); + } as AuditInterfaceTemplate); } catch (e) { this.context.logger.error( e, @@ -559,10 +561,10 @@ export abstract class BaseModel { } } -export const MakeModelClass = ( - config: ModelConfig +export const MakeModelClass = ( + config: ModelConfig ) => { - abstract class Model extends BaseModel { + abstract class Model extends BaseModel { getConfig() { return config; } diff --git a/packages/back-end/src/services/context.ts b/packages/back-end/src/services/context.ts index cc48754c1b5..03c4f1864e1 100644 --- a/packages/back-end/src/services/context.ts +++ b/packages/back-end/src/services/context.ts @@ -19,7 +19,7 @@ import { FactMetricModel } from "../models/FactMetricModel"; import { ProjectInterface } from "../../types/project"; import { findAllProjectsByOrganization } from "../models/ProjectModel"; import { addTags, getAllTags } from "../models/TagModel"; -import { AuditInterface } from "../../types/audit"; +import { AuditInterfaceInput } from "../../types/audit"; import { insertAudit } from "../models/AuditModel"; import { logger } from "../util/logger"; @@ -150,9 +150,7 @@ export class ReqContextClass { } // Record an audit log entry - public async auditLog( - data: Omit - ) { + public async auditLog(data: AuditInterfaceInput) { const auditUser = this.userId ? { id: this.userId, diff --git a/packages/back-end/src/types/Audit.ts b/packages/back-end/src/types/Audit.ts index d189778d210..a7f8968b319 100644 --- a/packages/back-end/src/types/Audit.ts +++ b/packages/back-end/src/types/Audit.ts @@ -1,14 +1,55 @@ -export const EntityType = [ - "experiment", - "feature", - "metric", - "datasource", - "comment", - "user", - "organization", - "savedGroup", - "archetype", - "team", -] as const; +export const entityEvents = { + attribute: ["create", "update", "delete"], + experiment: [ + "create", + "update", + "start", + "phase", + "phase", + "stop", + "status", + "archive", + "unarchive", + "delete", + "results", + "analysis", + "screenshot", + "screenshot", + "refresh", + "launchChecklist.updated", + "phase.delete", + "screenshot.delete", + "screenshot.create", + ], + environment: ["create", "update", "delete"], + feature: [ + "create", + "publish", + "revert", + "update", + "toggle", + "archive", + "delete", + ], + metric: ["autocreate", "create", "update", "delete", "analysis"], + datasource: ["create", "update", "delete", "import"], + comment: ["create", "update", "delete"], + user: ["create", "update", "delete", "invite"], + organization: ["create", "update", "delete"], + savedGroup: ["created", "deleted", "updated"], + archetype: ["created", "deleted", "updated"], + team: ["create", "delete", "update"], +} as const; +export type EntityEvents = typeof entityEvents; +export const EntityType = Object.keys(entityEvents) as [keyof EntityEvents]; export type EntityType = typeof EntityType[number]; + +export type EventTypes< + k extends EntityType +> = `${k}.${EntityEvents[k][number]}`; +export type EventType = EntityType extends unknown + ? EntityEvents[EntityType][number] extends unknown + ? `${EntityType}.${EntityEvents[EntityType][number]}` + : never + : never; diff --git a/packages/back-end/src/validators/openapi.ts b/packages/back-end/src/validators/openapi.ts index 85cfe3071f2..2a3090b6be3 100644 --- a/packages/back-end/src/validators/openapi.ts +++ b/packages/back-end/src/validators/openapi.ts @@ -240,6 +240,30 @@ export const putOrganizationValidator = { paramsSchema: z.object({ "id": z.string() }).strict(), }; +export const listEnvironmentsValidator = { + bodySchema: z.never(), + querySchema: z.never(), + paramsSchema: z.never(), +}; + +export const postEnvironmentValidator = { + bodySchema: z.object({ "id": z.string().describe("The ID of the new environment"), "description": z.string().describe("The description of the new environment").optional(), "toggleOnList": z.any().describe("Show toggle on feature list").optional(), "defaultState": z.any().describe("Default state for new features").optional(), "projects": z.array(z.string()).optional() }).strict(), + querySchema: z.never(), + paramsSchema: z.never(), +}; + +export const putEnvironmentValidator = { + bodySchema: z.object({ "description": z.string().describe("The description of the new environment").optional(), "toggleOnList": z.boolean().describe("Show toggle on feature list").optional(), "defaultState": z.boolean().describe("Default state for new features").optional(), "projects": z.array(z.string()).optional() }).strict(), + querySchema: z.never(), + paramsSchema: z.object({ "id": z.string() }).strict(), +}; + +export const deleteEnvironmentValidator = { + bodySchema: z.never(), + querySchema: z.never(), + paramsSchema: z.object({ "id": z.string() }).strict(), +}; + export const listFactTablesValidator = { bodySchema: z.never(), querySchema: z.object({ "limit": z.coerce.number().int().default(10), "offset": z.coerce.number().int().default(0), "datasourceId": z.string().optional(), "projectId": z.string().optional() }).strict(), diff --git a/packages/back-end/test/api/api.setup.ts b/packages/back-end/test/api/api.setup.ts new file mode 100644 index 00000000000..78469918efa --- /dev/null +++ b/packages/back-end/test/api/api.setup.ts @@ -0,0 +1,69 @@ +import mongoose from "mongoose"; +import { MongoMemoryServer } from "mongodb-memory-server"; +import { getAuthConnection } from "../../src/services/auth"; +import authenticateApiRequestMiddleware from "../../src/middleware/authenticateApiRequestMiddleware"; +import app from "../../src/app"; + +jest.mock("../../src/init/queue", () => ({ + queueInit: () => undefined, +})); + +jest.mock("../../src/services/auth", () => ({ + ...jest.requireActual("../../src/services/auth"), + getAuthConnection: () => ({ + middleware: jest.fn(), + }), +})); + +jest.mock("../../src/middleware/authenticateApiRequestMiddleware", () => ({ + ...jest.requireActual( + "../../src/middleware/authenticateApiRequestMiddleware" + ), + __esModule: true, + default: jest.fn(), +})); + +export const setupApp = () => { + let mongodb; + let reqContext; + const auditMock = jest.fn(); + const OLD_ENV = process.env; + + beforeAll(async () => { + mongodb = await MongoMemoryServer.create(); + const uri = mongodb.getUri(); + process.env.MONGO_URL = uri; + getAuthConnection().middleware.mockImplementation((req, res, next) => { + next(); + }); + + authenticateApiRequestMiddleware.mockImplementation((req, res, next) => { + req.audit = auditMock; + req.context = reqContext; + next(); + }); + }); + + afterAll(async () => { + await mongoose.connection.close(); + await mongodb.stop(); + process.env = OLD_ENV; + }); + + afterEach(async () => { + jest.clearAllMocks(); + const collections = mongoose.connection.collections; + for (const key in collections) { + const collection = collections[key]; + await collection.deleteMany(); + } + }); + + return { + app, + auditMock, + setReqContext: (v) => { + reqContext = v; + }, + }; +}; diff --git a/packages/back-end/test/api/environments.test.ts b/packages/back-end/test/api/environments.test.ts new file mode 100644 index 00000000000..adafe943b38 --- /dev/null +++ b/packages/back-end/test/api/environments.test.ts @@ -0,0 +1,661 @@ +import request from "supertest"; +import { updateOrganization } from "../../src/models/OrganizationModel"; +import { findAllProjectsByOrganization } from "../../src/models/ProjectModel"; +import { setupApp } from "./api.setup"; + +jest.mock("../../src/models/ProjectModel", () => ({ + findAllProjectsByOrganization: jest.fn(), +})); + +jest.mock("../../src/models/OrganizationModel", () => ({ + updateOrganization: jest.fn(), +})); + +describe("environements API", () => { + const { app, auditMock, setReqContext } = setupApp(); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it("can list all environments", async () => { + setReqContext({ + org: { + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canReadMultiProjectResource: () => true, + }, + }); + + const response = await request(app) + .get("/api/v1/environments") + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + description: "", + toggleOnList: false, + defaultState: false, + projects: [], + }, + ], + }); + }); + + it("can filter environments", async () => { + setReqContext({ + org: { + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canReadMultiProjectResource: (projects) => + (projects || []).includes("bla"), + }, + }); + + const response = await request(app) + .get("/api/v1/environments") + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + ], + }); + }); + + it("can delete environments", async () => { + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canDeleteEnvironment: () => true, + }, + }); + + const response = await request(app) + .delete("/api/v1/environments/env1") + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ deletedId: "env1" }); + expect(updateOrganization).toHaveBeenCalledWith("org1", { + settings: { environments: [{ id: "env2" }] }, + }); + expect(auditMock).toHaveBeenCalledWith({ + details: + '{"pre":{"id":"env1","description":"env1","toggleOnList":true,"defaultState":true,"projects":["bla"]},"context":{}}', + entity: { id: "env1", object: "environment" }, + event: "environment.delete", + }); + }); + + it("checks for permission to delete environments", async () => { + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canDeleteEnvironment: () => false, + throwPermissionError: () => { + throw new Error("permission error"); + }, + }, + }); + + const response = await request(app) + .delete("/api/v1/environments/env1") + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ message: "permission error" }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + }); + + it("can update environments", async () => { + findAllProjectsByOrganization.mockReturnValue([ + { id: "proj1" }, + { id: "proj2" }, + { id: "proj3" }, + ]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .put("/api/v1/environments/env1") + .send({ + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + environment: { + defaultState: false, + description: "new description", + id: "env1", + projects: ["proj1", "proj2"], + toggleOnList: false, + }, + }); + expect(updateOrganization).toHaveBeenCalledWith("org1", { + settings: { + environments: [ + { + id: "env1", + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }, + { id: "env2" }, + ], + }, + }); + expect(auditMock).toHaveBeenCalledWith({ + details: + '{"pre":{"id":"env1","description":"env1","toggleOnList":true,"defaultState":true,"projects":["bla"]},"post":{"id":"env1","projects":["proj1","proj2"],"description":"new description","toggleOnList":false,"defaultState":false},"context":{}}', + entity: { + id: "env1", + object: "environment", + }, + event: "environment.update", + }); + }); + + it("refuses to update projects when they do not exist", async () => { + findAllProjectsByOrganization.mockReturnValue([ + { id: "proj1" }, + { id: "proj3" }, + ]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .put("/api/v1/environments/env1") + .send({ + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "The following projects do not exist: proj2", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("validates update payload", async () => { + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .put("/api/v1/environments/env1") + .send({ + toggleOnList: "Gni", + defaultState: false, + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "Request body: [toggleOnList] Expected boolean, received string", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("checks for update permission", async () => { + findAllProjectsByOrganization.mockReturnValue([{ id: "bla" }]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => false, + throwPermissionError: () => { + throw new Error("permission error"); + }, + }, + }); + + const response = await request(app) + .put("/api/v1/environments/env1") + .send({ + toggleOnList: true, + defaultState: false, + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "permission error", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("can create environments", async () => { + findAllProjectsByOrganization.mockReturnValue([ + { id: "proj1" }, + { id: "proj2" }, + { id: "proj3" }, + ]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .post("/api/v1/environments") + .send({ + id: "env3", + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + environment: { + defaultState: false, + description: "new description", + id: "env3", + projects: ["proj1", "proj2"], + toggleOnList: false, + }, + }); + expect(updateOrganization).toHaveBeenCalledWith("org1", { + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + { + id: "env3", + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }, + ], + }, + }); + expect(auditMock).toHaveBeenCalledWith({ + details: + '{"post":{"id":"env3","projects":["proj1","proj2"],"description":"new description","toggleOnList":false,"defaultState":false},"context":{}}', + entity: { + id: "env3", + object: "environment", + }, + event: "environment.create", + }); + }); + + it("refuses to create with projects that do not exist", async () => { + findAllProjectsByOrganization.mockReturnValue([ + { id: "proj1" }, + { id: "proj3" }, + ]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .post("/api/v1/environments") + .send({ + id: "env3", + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "The following projects do not exist: proj2", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("validates create payload", async () => { + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .post("/api/v1/environments") + .send({ + toggleOnList: "Gni", + defaultState: false, + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "Request body: [id] Required", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("checks for create permission", async () => { + findAllProjectsByOrganization.mockReturnValue([{ id: "bla" }]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => false, + throwPermissionError: () => { + throw new Error("permission error"); + }, + }, + }); + + const response = await request(app) + .post("/api/v1/environments") + .send({ + id: "env3", + description: "new env", + toggleOnList: true, + defaultState: false, + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ + message: "permission error", + }); + expect(updateOrganization).not.toHaveBeenCalledWith(); + expect(auditMock).not.toHaveBeenCalledWith(); + }); + + it("fails to create environments with an empty ID", async () => { + findAllProjectsByOrganization.mockReturnValue([ + { id: "proj1" }, + { id: "proj2" }, + { id: "proj3" }, + ]); + + setReqContext({ + org: { + id: "org1", + settings: { + environments: [ + { + id: "env1", + description: "env1", + toggleOnList: true, + defaultState: true, + projects: ["bla"], + }, + { + id: "env2", + }, + ], + }, + }, + permissions: { + canCreateOrUpdateEnvironment: () => true, + }, + }); + + const response = await request(app) + .post("/api/v1/environments") + .send({ + id: "", + description: "new description", + toggleOnList: false, + defaultState: false, + projects: ["proj1", "proj2"], + }) + .set("Authorization", "Bearer foo"); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ message: "Environment ID cannot empty!" }); + expect(updateOrganization).not.toHaveBeenCalled(); + expect(auditMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/back-end/types/api.d.ts b/packages/back-end/types/api.d.ts index e6c718f56e6..0911add6c81 100644 --- a/packages/back-end/types/api.d.ts +++ b/packages/back-end/types/api.d.ts @@ -4,7 +4,7 @@ import { } from "@growthbook/growthbook"; import { EventAuditUser } from "../src/events/event-types"; import { PermissionFunctions } from "../src/types/AuthRequest"; -import { AuditInterface } from "./audit"; +import { AuditInterfaceInput } from "./audit"; import { ExperimentStatus } from "./experiment"; import { OrganizationInterface, ReqContext } from "./organization"; import { UserInterface } from "./user"; @@ -49,9 +49,7 @@ export type ApiRequestLocals = PermissionFunctions & { user?: UserInterface; organization: OrganizationInterface; eventAudit: EventAuditUser; - audit: ( - data: Omit - ) => Promise; + audit: (data: AuditInterfaceInput) => Promise; context: ApiReqContext; }; diff --git a/packages/back-end/types/audit.d.ts b/packages/back-end/types/audit.d.ts index ef8a8352a5e..1d1a0ad5eb9 100644 --- a/packages/back-end/types/audit.d.ts +++ b/packages/back-end/types/audit.d.ts @@ -1,60 +1,5 @@ -import { EntityType } from "../src/types/Audit"; - -export type EventType = - | "attribute.create" - | "attribute.update" - | "attribute.delete" - | "experiment.create" - | "experiment.update" - | "experiment.start" - | "experiment.phase" - | "experiment.phase.delete" - | "experiment.stop" - | "experiment.status" - | "experiment.archive" - | "experiment.unarchive" - | "experiment.delete" - | "experiment.results" - | "experiment.analysis" - | "experiment.screenshot.create" - | "experiment.screenshot.delete" - | "experiment.refresh" - | "experiment.launchChecklist.updated" - | "feature.create" - | "feature.publish" - | "feature.revert" - | "feature.update" - | "feature.toggle" - | "feature.archive" - | "feature.delete" - | "metric.autocreate" - | "metric.create" - | "metric.update" - | "metric.delete" - | "metric.analysis" - | "datasource.create" - | "datasource.update" - | "datasource.delete" - | "datasource.import" - | "comment.create" - | "comment.update" - | "comment.delete" - | "user.create" - | "user.update" - | "user.delete" - | "user.invite" - | "organization.create" - | "organization.update" - | "organization.delete" - | "savedGroup.created" - | "savedGroup.deleted" - | "savedGroup.updated" - | "archetype.created" - | "archetype.deleted" - | "archetype.updated" - | "team.create" - | "team.delete" - | "team.update"; +import { EntityType, EntityEvents } from "../src/types/Audit"; +export { EventType } from "../src/types/Audit"; export interface AuditUserLoggedIn { id: string; @@ -66,21 +11,31 @@ export interface AuditUserApiKey { apiKey: string; } -export interface AuditInterface { - id: string; - organization: string; - user: AuditUserLoggedIn | AuditUserApiKey; - event: EventType; - entity: { - object: EntityType; - id: string; - name?: string; - }; - parent?: { - object: EntityType; - id: string; - }; - reason?: string; - details?: string; - dateCreated: Date; -} +export type AuditInterfaceTemplate = Entity extends EntityType + ? { + id: string; + organization: string; + user: AuditUserLoggedIn | AuditUserApiKey; + event: `${Entity}.${EntityEvents[Entity][number]}`; + entity: { + object: Entity; + id: string; + name?: string; + }; + parent?: { + object: Entity; + id: string; + }; + reason?: string; + details?: string; + dateCreated: Date; + } + : never; + +export type AuditInterface = AuditInterfaceTemplate; + +export type AuditInterfaceInputTemplate = Interface extends unknown + ? Omit + : never; + +export type AuditInterfaceInput = AuditInterfaceInputTemplate; diff --git a/packages/back-end/types/openapi.d.ts b/packages/back-end/types/openapi.d.ts index 9348b4d3e36..d07a2674942 100644 --- a/packages/back-end/types/openapi.d.ts +++ b/packages/back-end/types/openapi.d.ts @@ -199,6 +199,18 @@ export interface paths { /** Edit a single organization (only for super admins on multi-org Enterprise Plan only) */ put: operations["putOrganization"]; }; + "/environments": { + /** Get the organization's environments */ + get: operations["listEnvironments"]; + /** Create a new environment */ + post: operations["postEnvironment"]; + }; + "/environments/${id}": { + /** Update an environment */ + put: operations["putEnvironment"]; + /** Deletes a single environment */ + delete: operations["deleteEnvironment"]; + }; "/fact-tables": { /** Get all fact tables */ get: operations["listFactTables"]; @@ -386,6 +398,13 @@ export interface components { statsEngine?: string; }; }; + Environment: { + id: string; + description: string; + toggleOnList: boolean; + defaultState: boolean; + projects: (string)[]; + }; Segment: { id: string; owner: string; @@ -4994,6 +5013,112 @@ export interface operations { }; }; }; + listEnvironments: { + /** Get the organization's environments */ + responses: { + 200: { + content: { + "application/json": { + environments: ({ + id: string; + description: string; + toggleOnList: boolean; + defaultState: boolean; + projects: (string)[]; + })[]; + }; + }; + }; + }; + }; + postEnvironment: { + /** Create a new environment */ + requestBody: { + content: { + "application/json": { + /** @description The ID of the new environment */ + id: string; + /** @description The description of the new environment */ + description?: string; + /** @description Show toggle on feature list */ + toggleOnList?: any; + /** @description Default state for new features */ + defaultState?: any; + projects?: (string)[]; + }; + }; + }; + responses: { + 200: { + content: { + "application/json": { + environment: { + id: string; + description: string; + toggleOnList: boolean; + defaultState: boolean; + projects: (string)[]; + }; + }; + }; + }; + }; + }; + putEnvironment: { + /** Update an environment */ + parameters: { + /** @description The id of the requested resource */ + path: { + id: string; + }; + }; + requestBody: { + content: { + "application/json": { + /** @description The description of the new environment */ + description?: string; + /** @description Show toggle on feature list */ + toggleOnList?: boolean; + /** @description Default state for new features */ + defaultState?: boolean; + projects?: (string)[]; + }; + }; + }; + responses: { + 200: { + content: { + "application/json": { + environment: { + id: string; + description: string; + toggleOnList: boolean; + defaultState: boolean; + projects: (string)[]; + }; + }; + }; + }; + }; + }; + deleteEnvironment: { + /** Deletes a single environment */ + parameters: { + /** @description The id of the requested resource */ + path: { + id: string; + }; + }; + responses: { + 200: { + content: { + "application/json": { + deletedId: string; + }; + }; + }; + }; + }; listFactTables: { /** Get all fact tables */ parameters: { @@ -6218,6 +6343,7 @@ export type ApiPaginationFields = components["schemas"]["PaginationFields"]; export type ApiDimension = components["schemas"]["Dimension"]; export type ApiMetric = components["schemas"]["Metric"]; export type ApiProject = components["schemas"]["Project"]; +export type ApiEnvironment = components["schemas"]["Environment"]; export type ApiSegment = components["schemas"]["Segment"]; export type ApiFeature = components["schemas"]["Feature"]; export type ApiFeatureEnvironment = components["schemas"]["FeatureEnvironment"]; @@ -6281,6 +6407,10 @@ export type DeleteSavedGroupResponse = operations["deleteSavedGroup"]["responses export type ListOrganizationsResponse = operations["listOrganizations"]["responses"]["200"]["content"]["application/json"]; export type PostOrganizationResponse = operations["postOrganization"]["responses"]["200"]["content"]["application/json"]; export type PutOrganizationResponse = operations["putOrganization"]["responses"]["200"]["content"]["application/json"]; +export type ListEnvironmentsResponse = operations["listEnvironments"]["responses"]["200"]["content"]["application/json"]; +export type PostEnvironmentResponse = operations["postEnvironment"]["responses"]["200"]["content"]["application/json"]; +export type PutEnvironmentResponse = operations["putEnvironment"]["responses"]["200"]["content"]["application/json"]; +export type DeleteEnvironmentResponse = operations["deleteEnvironment"]["responses"]["200"]["content"]["application/json"]; export type ListFactTablesResponse = operations["listFactTables"]["responses"]["200"]["content"]["application/json"]; export type PostFactTableResponse = operations["postFactTable"]["responses"]["200"]["content"]["application/json"]; export type GetFactTableResponse = operations["getFactTable"]["responses"]["200"]["content"]["application/json"]; diff --git a/yarn.lock b/yarn.lock index c633412ef55..88ee8353c1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8479,7 +8479,7 @@ arrify@^2.0.0, arrify@^2.0.1: resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz" integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== -asap@^2.0.3: +asap@^2.0.0, asap@^2.0.3: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY= @@ -8542,6 +8542,13 @@ async-lock@^1.4.0: resolved "https://registry.yarnpkg.com/async-lock/-/async-lock-1.4.0.tgz#c8b6630eff68fbbdd8a5b6eb763dac3bfbb8bf02" integrity sha512-coglx5yIWuetakm3/1dsX9hxCNox22h7+V80RQOu2XUUMidtArxKoZoOtHUPuR84SycKTXzgGzAUR5hJxujyJQ== +async-mutex@^0.4.0: + version "0.4.1" + resolved "https://registry.yarnpkg.com/async-mutex/-/async-mutex-0.4.1.tgz#bccf55b96f2baf8df90ed798cb5544a1f6ee4c2c" + integrity sha512-WfoBo4E/TbCX1G95XTjbWTE3X2XLG0m1Xbv2cwOtuPdyH9CZvnaA5nCt1ucjaKEgW2A5IF71hxrRhr83Je5xjA== + dependencies: + tslib "^2.4.0" + async-retry@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz" @@ -8658,6 +8665,11 @@ axobject-query@^3.2.1: dependencies: dequal "^2.0.3" +b4a@^1.6.4: + version "1.6.6" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.6.6.tgz#a4cc349a3851987c3c4ac2d7785c18744f6da9ba" + integrity sha512-5Tk1HLk6b6ctmjIkAcU/Ujv/1WqiDl0F0JdRCR80VsOcUlHcu7pWeWRlOqQLHfDEsVx9YH/aif5AG4ehoCtTmg== + babel-jest@^27.5.1: version "27.5.1" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz" @@ -8818,6 +8830,11 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bare-events@^2.2.0: + version "2.2.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.2.2.tgz#a98a41841f98b2efe7ecc5c5468814469b018078" + integrity sha512-h7z00dWdG0PYOQEvChhOSWvOfkIKsdZGkWr083FgN/HyoQuebSew/cgirYqh9SCuy/hRvxc5Vy6Fw8xAmYHLkQ== + base64-js@^1.0.2, base64-js@^1.3.0, base64-js@^1.3.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" @@ -9030,11 +9047,21 @@ bson@^4.7.2: dependencies: buffer "^5.6.0" +bson@^5.5.0: + version "5.5.1" + resolved "https://registry.yarnpkg.com/bson/-/bson-5.5.1.tgz#f5849d405711a7f23acdda9a442375df858e6833" + integrity sha512-ix0EwukN2EpC0SRWIj/7B5+A6uQMQy6KMREI9qQqvgpkV2frH63T0UDVd1SYedL6dNCmDBYB3QtXi4ISk9YT+g== + btoa-lite@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337" integrity sha512-gvW7InbIyF8AicrqWoptdW08pUxuhq8BEgowNajy9RhiE86fmGAGl+bLKo6oB8QP0CkqHLowfN0oJdKC/J6LbA== +buffer-crc32@~0.2.3: + version "0.2.13" + resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" + integrity sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ== + buffer-equal-constant-time@1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz" @@ -9589,6 +9616,11 @@ commondir@^1.0.1: resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" integrity sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs= +component-emitter@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.1.tgz#ef1d5796f7d93f135ee6fb684340b26403c97d17" + integrity sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ== + compressible@^2.0.12, compressible@~2.0.16: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz" @@ -9702,6 +9734,11 @@ cookie@^0.4.1: resolved "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz" integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== +cookiejar@^2.1.4: + version "2.1.4" + resolved "https://registry.yarnpkg.com/cookiejar/-/cookiejar-2.1.4.tgz#ee669c1fea2cf42dc31585469d193fef0d65771b" + integrity sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw== + core-js-compat@^3.25.1: version "3.26.0" resolved "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.26.0.tgz" @@ -10251,6 +10288,14 @@ devlop@^1.0.0, devlop@^1.1.0: dependencies: dequal "^2.0.0" +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + diff-match-patch@^1.0.5: version "1.0.5" resolved "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz" @@ -11300,6 +11345,11 @@ fast-equals@^2.0.3: resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-2.0.4.tgz#3add9410585e2d7364c2deeb6a707beadb24b927" integrity sha512-caj/ZmjHljPrZtbzJ3kfH5ia/k4mTJe/qSiXAGzxZWRZgsgDV0cvNaQULqUX8t0/JVlzzEdYOwCN5DmzTxoD4w== +fast-fifo@^1.1.0, fast-fifo@^1.2.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/fast-fifo/-/fast-fifo-1.3.2.tgz#286e31de96eb96d38a97899815740ba2a4f3640c" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@^3.2.11, fast-glob@^3.2.12, fast-glob@^3.2.5, fast-glob@^3.2.9: version "3.2.12" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz" @@ -11480,6 +11530,15 @@ finalhandler@1.2.0: statuses "2.0.1" unpipe "~1.0.0" +find-cache-dir@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" + integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== + dependencies: + commondir "^1.0.1" + make-dir "^3.0.2" + pkg-dir "^4.1.0" + find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -11645,6 +11704,15 @@ format@^0.2.0: resolved "https://registry.npmjs.org/format/-/format-0.2.2.tgz" integrity sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww== +formidable@^3.5.1: + version "3.5.1" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.1.tgz#9360a23a656f261207868b1484624c4c8d06ee1a" + integrity sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + forwarded@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz" @@ -12440,6 +12508,11 @@ help-me@^4.0.1: glob "^8.0.0" readable-stream "^3.6.0" +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + highlight.js@11.1.0: version "11.1.0" resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-11.1.0.tgz" @@ -12570,7 +12643,7 @@ https-proxy-agent@^5.0.0: agent-base "6" debug "4" -https-proxy-agent@^7.0.1: +https-proxy-agent@^7.0.1, https-proxy-agent@^7.0.4: version "7.0.4" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168" integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg== @@ -14606,7 +14679,7 @@ make-dir@^2.1.0: pify "^4.0.1" semver "^5.6.0" -make-dir@^3.0.0: +make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz" integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== @@ -14921,7 +14994,7 @@ merge2@^1.3.0, merge2@^1.4.1: resolved "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -methods@~1.1.2: +methods@^1.1.2, methods@~1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== @@ -15224,6 +15297,11 @@ mime@1.6.0: resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +mime@2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + mime@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz" @@ -15366,6 +15444,32 @@ mongodb-connection-string-url@^2.6.0: "@types/whatwg-url" "^8.2.1" whatwg-url "^11.0.0" +mongodb-memory-server-core@9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/mongodb-memory-server-core/-/mongodb-memory-server-core-9.2.0.tgz#d83e52633df910d8a48c1ff27de139fd4ab7f2d9" + integrity sha512-9SWZEy+dGj5Fvm5RY/mtqHZKS64o4heDwReD4SsfR7+uNgtYo+JN41kPCcJeIH3aJf04j25i5Dia2s52KmsMPA== + dependencies: + async-mutex "^0.4.0" + camelcase "^6.3.0" + debug "^4.3.4" + find-cache-dir "^3.3.2" + follow-redirects "^1.15.6" + https-proxy-agent "^7.0.4" + mongodb "^5.9.1" + new-find-package-json "^2.0.0" + semver "^7.6.0" + tar-stream "^3.1.7" + tslib "^2.6.2" + yauzl "^3.1.3" + +mongodb-memory-server@^9.2.0: + version "9.2.0" + resolved "https://registry.yarnpkg.com/mongodb-memory-server/-/mongodb-memory-server-9.2.0.tgz#9fe8a0128f5b7409041895ce9e56ed28b2cf337d" + integrity sha512-w/usKdYtby5EALERxmA0+et+D0brP0InH3a26shNDgGefXA61hgl6U0P3IfwqZlEGRZdkbZig3n57AHZgDiwvg== + dependencies: + mongodb-memory-server-core "9.2.0" + tslib "^2.6.2" + mongodb@4.17.2, mongodb@^4.17.2: version "4.17.2" resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-4.17.2.tgz#237c0534e36a3449bd74c6bf6d32f87a1ca7200c" @@ -15390,6 +15494,17 @@ mongodb@^4.11.0: "@aws-sdk/credential-providers" "^3.186.0" saslprep "^1.0.3" +mongodb@^5.9.1: + version "5.9.2" + resolved "https://registry.yarnpkg.com/mongodb/-/mongodb-5.9.2.tgz#39a73b9fbc87ac9d9c1aaf8aab5c5bb69e2b913e" + integrity sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ== + dependencies: + bson "^5.5.0" + mongodb-connection-string-url "^2.6.0" + socks "^2.7.1" + optionalDependencies: + "@mongodb-js/saslprep" "^1.1.0" + mongoose@^6.11.6: version "6.12.7" resolved "https://registry.yarnpkg.com/mongoose/-/mongoose-6.12.7.tgz#97adb534424b2a87a440a592913aae1c12068fc4" @@ -15528,6 +15643,13 @@ netmask@^2.0.2: resolved "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz" integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== +new-find-package-json@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/new-find-package-json/-/new-find-package-json-2.0.0.tgz#96553638781db35061f351e8ccb4d07126b6407d" + integrity sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew== + dependencies: + debug "^4.3.4" + next@^14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/next/-/next-14.1.0.tgz#b31c0261ff9caa6b4a17c5af019ed77387174b69" @@ -16414,6 +16536,11 @@ path-type@^4.0.0: resolved "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz" integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg== + pg-cloudflare@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/pg-cloudflare/-/pg-cloudflare-1.1.1.tgz#e6d5833015b170e23ae819e8c5d7eaedb472ca98" @@ -16591,7 +16718,7 @@ pirates@^4.0.6: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== -pkg-dir@^4.2.0: +pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz" integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== @@ -16950,6 +17077,11 @@ queue-microtask@^1.2.2: resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +queue-tick@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/queue-tick/-/queue-tick-1.0.1.tgz#f6f07ac82c1fd60f82e098b417a80e52f1f4c142" + integrity sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag== + quick-format-unescaped@^4.0.3: version "4.0.4" resolved "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz" @@ -18017,6 +18149,11 @@ semver@^7.5.3, semver@^7.5.4: dependencies: lru-cache "^6.0.0" +semver@^7.6.0: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + semver@~7.0.0: version "7.0.0" resolved "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz" @@ -18537,6 +18674,16 @@ streamsearch@^1.1.0: resolved "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz" integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== +streamx@^2.15.0: + version "2.16.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.16.1.tgz#2b311bd34832f08aa6bb4d6a80297c9caef89614" + integrity sha512-m9QYj6WygWyWa3H1YY69amr4nVgy61xfjys7xO7kviL5rfIEc2naf+ewFiOA+aEJD7y0JO3h2GoiUv4TDwEGzQ== + dependencies: + fast-fifo "^1.1.0" + queue-tick "^1.0.1" + optionalDependencies: + bare-events "^2.2.0" + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz" @@ -18846,6 +18993,29 @@ stylis@4.0.13: resolved "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz" integrity sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag== +superagent@^9.0.1: + version "9.0.2" + resolved "https://registry.yarnpkg.com/superagent/-/superagent-9.0.2.tgz#a18799473fc57557289d6b63960610e358bdebc1" + integrity sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w== + dependencies: + component-emitter "^1.3.0" + cookiejar "^2.1.4" + debug "^4.3.4" + fast-safe-stringify "^2.1.1" + form-data "^4.0.0" + formidable "^3.5.1" + methods "^1.1.2" + mime "2.6.0" + qs "^6.11.0" + +supertest@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.0.0.tgz#cac53b3d6872a0b317980b2b0cfa820f09cd7634" + integrity sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA== + dependencies: + methods "^1.1.2" + superagent "^9.0.1" + supports-color@^5.3.0, supports-color@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz" @@ -18943,6 +19113,15 @@ tapable@^2.2.0: resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz" integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== +tar-stream@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.1.7.tgz#24b3fb5eabada19fe7338ed6d26e5f7c482e792b" + integrity sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ== + dependencies: + b4a "^1.6.4" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tarn@^3.0.1, tarn@^3.0.2: version "3.0.2" resolved "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz" @@ -20448,6 +20627,14 @@ yarn@^1.22.18: resolved "https://registry.npmjs.org/yarn/-/yarn-1.22.19.tgz" integrity sha512-/0V5q0WbslqnwP91tirOvldvYISzaqhClxzyUKXYxs07yUILIs5jx/k6CFe8bvKSkds5w+eiOqta39Wk3WxdcQ== +yauzl@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-3.1.3.tgz#f61c17ad1a09403bc7adb01dfb302a9e74bf4a50" + integrity sha512-JCCdmlJJWv7L0q/KylOekyRaUrdEoUxWkWVcgorosTROCFWiS9p2NNPE9Yb91ak7b1N5SxAZEliWpspbZccivw== + dependencies: + buffer-crc32 "~0.2.3" + pend "~1.2.0" + yn@3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz"