diff --git a/codegen.yaml b/codegen.yaml index 82929b1..e498e81 100644 --- a/codegen.yaml +++ b/codegen.yaml @@ -99,12 +99,11 @@ generates: ContentType: ../models/rules/ItemTypeModel.js#ItemType DerivedFieldSource: ../services/derivedFieldsService/helpers.js#DerivedFieldSpecSource HashBank: ../models/HashBankModel.js#HashBank - Org: ../models/OrgModel.js#Org - # NB: for now we always resolve MatchingBanks with an Org model, and - # then call methods on the org model to resolve the sub-fields. This is - # a bit unusual, as matchingBanks could in theory be a standalone type, - # so we might have to expand this later. - MatchingBanks: ../models/OrgModel.js#Org + Org: ../graphql/datasources/orgKyselyPersistence.js#GraphQLOrgParent + # NB: `MatchingBanks` is currently resolved by returning the `Org` + # parent, and its sub-resolvers only read `org.id`. A bit unusual, as + # `MatchingBanks` could be a standalone type — may expand later. + MatchingBanks: ../graphql/datasources/orgKyselyPersistence.js#GraphQLOrgParent User: ../models/UserModel.js#User LocationBank: ./datasources/LocationBankApi.js#LocationBankWithoutFullPlacesAPIResponse # TODO(Kysely migration): these parents will flip to diff --git a/server/bin/create-org-and-user.ts b/server/bin/create-org-and-user.ts index 778d34e..6906a7e 100644 --- a/server/bin/create-org-and-user.ts +++ b/server/bin/create-org-and-user.ts @@ -17,6 +17,7 @@ import { uid } from 'uid'; import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { kyselyOrgInsert } from '../graphql/datasources/orgKyselyPersistence.js'; import getBottle from '../iocContainer/index.js'; import { hashPassword } from '../services/userManagementService/index.js'; @@ -64,19 +65,11 @@ async function createOrgAndUser() { const orgId = uid(); const userId = uid(); - // Create the organization - const org = await container.Sequelize.Org.create({ - id: orgId, - email: argv.email, - name: argv.name, - websiteUrl: argv.website, - }); - - // Create signing keys + // Create signing keys await container.SigningKeyPairService.createAndStoreSigningKeys(orgId); - // Create API key - const { apiKey: rawApiKey, record: apiKeyRecord } = + // Create API key + const { apiKey: rawApiKey, record: apiKeyRecord } = await container.ApiKeyService.createApiKey( orgId, 'Main API Key', @@ -84,9 +77,14 @@ async function createOrgAndUser() { null, ); - // Update the org with the API key ID - org.apiKeyId = apiKeyRecord.id; - await org.save(); + const org = await kyselyOrgInsert({ + db: container.KyselyPg, + id: orgId, + email: argv.email, + name: argv.name, + websiteUrl: argv.website, + apiKeyId: apiKeyRecord.id, + }); // Initialize org settings await Promise.all([ diff --git a/server/graphql/datasources/OrgApi.test.ts b/server/graphql/datasources/OrgApi.test.ts new file mode 100644 index 0000000..e92bef2 --- /dev/null +++ b/server/graphql/datasources/OrgApi.test.ts @@ -0,0 +1,171 @@ +import { uid } from 'uid'; + +import createOrg from '../../test/fixtureHelpers/createOrg.js'; +import { makeMockedServer } from '../../test/setupMockedServer.js'; +import { makeTestWithFixture } from '../../test/utils.js'; +import { CoopError } from '../../utils/errors.js'; + +describe('OrgAPI', () => { + const testWithFixture = makeTestWithFixture(async () => { + const { deps, shutdown } = await makeMockedServer(); + const { org, cleanup: orgCleanup } = await createOrg( + { + KyselyPg: deps.KyselyPg, + ModerationConfigService: deps.ModerationConfigService, + ApiKeyService: deps.ApiKeyService, + }, + uid(), + ); + return { + deps, + org, + async cleanup() { + await orgCleanup(); + await shutdown(); + }, + }; + }); + + describe('getGraphQLOrgFromId', () => { + testWithFixture('returns the org parent for an existing id', async ({ + deps, + org, + }) => { + const result = await deps.OrgAPIDataSource.getGraphQLOrgFromId(org.id); + expect(result).toMatchObject({ + id: org.id, + name: org.name, + email: org.email, + }); + }); + + testWithFixture( + 'throws when the org does not exist (replaces Sequelize rejectOnEmpty)', + async ({ deps }) => { + const missingId = `missing-${uid()}`; + await expect( + deps.OrgAPIDataSource.getGraphQLOrgFromId(missingId), + ).rejects.toThrow(/Organization not found/); + }, + ); + }); + + describe('updateOrgInfo', () => { + testWithFixture( + 'throws when the org does not exist', + async ({ deps }) => { + await expect( + deps.OrgAPIDataSource.updateOrgInfo(`missing-${uid()}`, { + name: 'whatever', + }), + ).rejects.toThrow(/Organization not found/); + }, + ); + + testWithFixture( + 'returns the updated parent when the org exists', + async ({ deps, org }) => { + const newName = `Renamed_${uid()}`; + const result = await deps.OrgAPIDataSource.updateOrgInfo(org.id, { + name: newName, + }); + expect(result.id).toBe(org.id); + expect(result.name).toBe(newName); + }, + ); + + testWithFixture( + 'throws a BadRequest with a pointer for malformed email', + async ({ deps, org }) => { + await expect( + deps.OrgAPIDataSource.updateOrgInfo(org.id, { + email: 'not-an-email', + }), + ).rejects.toMatchObject({ + name: 'BadRequestError', + status: 400, + pointer: '/input/email', + }); + }, + ); + + testWithFixture( + 'throws a BadRequest for malformed websiteUrl (javascript: scheme)', + async ({ deps, org }) => { + await expect( + deps.OrgAPIDataSource.updateOrgInfo(org.id, { + // eslint-disable-next-line no-script-url + websiteUrl: 'javascript:alert(1)', + }), + ).rejects.toMatchObject({ + name: 'BadRequestError', + pointer: '/input/websiteUrl', + }); + }, + ); + + testWithFixture( + 'throws a BadRequest for empty name', + async ({ deps, org }) => { + await expect( + deps.OrgAPIDataSource.updateOrgInfo(org.id, { name: '' }), + ).rejects.toBeInstanceOf(CoopError); + }, + ); + + testWithFixture( + 'does not touch the DB when validation fails (org not found only surfaces after validation passes)', + async ({ deps }) => { + // If validation ran AFTER the DB lookup we'd get "Organization not + // found" here; ensure we see the BadRequest instead. + await expect( + deps.OrgAPIDataSource.updateOrgInfo(`missing-${uid()}`, { + email: 'not-an-email', + }), + ).rejects.toMatchObject({ + name: 'BadRequestError', + pointer: '/input/email', + }); + }, + ); + }); + + describe('createOrg', () => { + testWithFixture( + 'throws a BadRequest with /input/website pointer for bad website', + async ({ deps }) => { + await expect( + deps.OrgAPIDataSource.createOrg({ + input: { + name: `NewOrg_${uid()}`, + email: `new_${uid()}@example.com`, + // eslint-disable-next-line no-script-url + website: 'javascript:alert(1)', + }, + }), + ).rejects.toMatchObject({ + name: 'BadRequestError', + pointer: '/input/website', + }); + }, + ); + + testWithFixture( + 'throws a BadRequest for malformed email', + async ({ deps }) => { + await expect( + deps.OrgAPIDataSource.createOrg({ + input: { + name: `NewOrg_${uid()}`, + email: 'not-an-email', + website: 'https://example.com', + }, + }), + ).rejects.toMatchObject({ + name: 'BadRequestError', + pointer: '/input/email', + }); + }, + ); + }); +}); diff --git a/server/graphql/datasources/OrgApi.ts b/server/graphql/datasources/OrgApi.ts index ccd7ed7..c5ce2f5 100644 --- a/server/graphql/datasources/OrgApi.ts +++ b/server/graphql/datasources/OrgApi.ts @@ -9,6 +9,7 @@ import { b64EncodeArrayBuffer } from '../../utils/encoding.js'; import { CoopError, ErrorType, + makeBadRequestError, type ErrorInstanceData, isCoopErrorOfType, } from '../../utils/errors.js'; @@ -18,13 +19,26 @@ import { type GQLMutationCreateOrgArgs, type GQLRequestDemoInput, } from '../generated.js'; +import { + kyselyOrgFindAll, + kyselyOrgFindByEmail, + kyselyOrgFindById, + kyselyOrgFindByName, + kyselyOrgInsert, + kyselyOrgUpdate, + type GraphQLOrgParent, +} from './orgKyselyPersistence.js'; +import { + validateOrgCreateInput, + validateOrgUpdatePatch, + type OrgValidationFailure, +} from './orgValidation.js'; class OrgAPI { constructor( private readonly orgCreationLogger: Dependencies['OrgCreationLogger'], private readonly apiKeyService: Dependencies['ApiKeyService'], private readonly sendEmail: Dependencies['sendEmail'], - private readonly sequelize: Dependencies['Sequelize'], private readonly signingKeyPairService: Dependencies['SigningKeyPairService'], private readonly tracer: Dependencies['Tracer'], private readonly moderationConfigService: Dependencies['ModerationConfigService'], @@ -32,20 +46,30 @@ class OrgAPI { private readonly config: Dependencies['ConfigService'], private readonly orgSettingsService: Dependencies['OrgSettingsService'], private readonly manualReviewToolService: Dependencies['ManualReviewToolService'], - ) { - } + private readonly kysely: Dependencies['KyselyPg'], + private readonly sequelize: Dependencies['Sequelize'], + ) {} async createOrg(params: GQLMutationCreateOrgArgs) { const { email, name, website } = params.input; - const existingOrgByName = await this.sequelize.Org.findOne({ - where: { name }, + + const validation = validateOrgCreateInput({ + name, + email, + websiteUrl: website, }); + if (!validation.ok) { + throw orgValidationFailureToBadRequestError( + validation.failure, + 'createOrg', + ); + } + + const existingOrgByName = await kyselyOrgFindByName(this.kysely, name); if (existingOrgByName != null) { throw makeOrgNameExistsError({ shouldErrorSpan: true }); } - const existingOrgByEmail = await this.sequelize.Org.findOne({ - where: { email }, - }); + const existingOrgByEmail = await kyselyOrgFindByEmail(this.kysely, email); if (existingOrgByEmail != null) { throw makeOrgEmailExistsError({ shouldErrorSpan: true }); @@ -67,7 +91,8 @@ class OrgAPI { await this.signingKeyPairService.createAndStoreSigningKeys(id); try { - const org = await this.sequelize.Org.create({ + const org = await kyselyOrgInsert({ + db: this.kysely, id, email, name, @@ -100,9 +125,10 @@ class OrgAPI { // Create invite token and optionally send email async inviteUser(input: GQLInviteUserInput, orgId: string) { const { email, role } = input; - const org = await this.sequelize.Org.findByPk(orgId, { - rejectOnEmpty: true, - }); + const org = await kyselyOrgFindById(this.kysely, orgId); + if (org == null) { + throw new Error(`Organization not found: ${orgId}`); + } const token = await this.userManagementService.createInviteUserToken({ email, @@ -171,12 +197,16 @@ class OrgAPI { return true; } - async getGraphQLOrgFromId(id: string) { - return this.sequelize.Org.findByPk(id, { rejectOnEmpty: true }); + async getGraphQLOrgFromId(id: string): Promise { + const org = await kyselyOrgFindById(this.kysely, id); + if (org == null) { + throw new Error(`Organization not found: ${id}`); + } + return org; } - async getAllGraphQLOrgs() { - return this.sequelize.Org.findAll(); + async getAllGraphQLOrgs(): Promise { + return kyselyOrgFindAll(this.kysely); } async updateOrgInfo( @@ -187,28 +217,41 @@ class OrgAPI { websiteUrl?: string | null; onCallAlertEmail?: string | null; }, - ) { - const org = await this.sequelize.Org.findByPk(orgId); - if (!org) { - throw new Error('Organization not found'); + ): Promise { + const validation = validateOrgUpdatePatch(input); + if (!validation.ok) { + throw orgValidationFailureToBadRequestError( + validation.failure, + 'updateOrgInfo', + ); } - if (input.name != null) { - org.name = input.name; - } - if (input.email != null) { - org.email = input.email; - } - if (input.websiteUrl != null && input.websiteUrl !== '') { - org.websiteUrl = input.websiteUrl; - } - if (input.onCallAlertEmail !== undefined) { - org.onCallAlertEmail = input.onCallAlertEmail ?? undefined; + const updated = await kyselyOrgUpdate(this.kysely, orgId, { + name: input.name ?? undefined, + email: input.email ?? undefined, + websiteUrl: input.websiteUrl ?? undefined, + onCallAlertEmail: input.onCallAlertEmail, + }); + if (updated == null) { + throw new Error('Organization not found'); } - await org.save(); + return updated; + } - return org; + /** + * Legacy GraphQL `ContentType` parents still use Sequelize `getActions` on + * item types; load them from the ORM until item types are fully migrated. + */ + async getSequelizeContentTypesForOrg(orgId: string) { + return this.sequelize.ItemType.findAll({ + where: { orgId }, + }); + } + + /** GraphQL `Org.users` / permission filters still use Sequelize `User` models. */ + async getOrgUsersForGraphQL(orgId: string) { + return this.sequelize.User.findAll({ where: { orgId } }); } // TODO: ApiKeyService should maybe be its own dataSource, @@ -267,6 +310,22 @@ export type OrgErrorType = | 'InviteUserTokenExpiredError' | 'InviteUserTokenMissingError'; +function orgValidationFailureToBadRequestError( + failure: OrgValidationFailure, + mutation: 'createOrg' | 'updateOrgInfo', +) { + // `createOrg` exposes `websiteUrl` as `website` in its GraphQL input; + // `updateOrgInfo` uses the same field name. + const gqlField = + mutation === 'createOrg' && failure.field === 'websiteUrl' + ? 'website' + : failure.field; + return makeBadRequestError(failure.message, { + pointer: `/input/${gqlField}`, + shouldErrorSpan: false, + }); +} + export const makeOrgEmailExistsError = (data: ErrorInstanceData) => new CoopError({ status: 409, @@ -308,7 +367,6 @@ export default inject( 'OrgCreationLogger', 'ApiKeyService', 'sendEmail', - 'Sequelize', 'SigningKeyPairService', 'Tracer', 'ModerationConfigService', @@ -316,6 +374,8 @@ export default inject( 'ConfigService', 'OrgSettingsService', 'ManualReviewToolService', + 'KyselyPg', + 'Sequelize', ], OrgAPI, ); diff --git a/server/graphql/datasources/RuleApi.test.ts b/server/graphql/datasources/RuleApi.test.ts index 4cba511..926c847 100644 --- a/server/graphql/datasources/RuleApi.test.ts +++ b/server/graphql/datasources/RuleApi.test.ts @@ -27,9 +27,11 @@ describe('RuleAPI', () => { const { deps, shutdown } = await makeMockedServer(); const { ModerationConfigService } = deps; const { org, cleanup: orgCleanup } = await createOrg( - deps.Sequelize, - ModerationConfigService, - deps.ApiKeyService, + { + KyselyPg: deps.KyselyPg, + ModerationConfigService, + ApiKeyService: deps.ApiKeyService, + }, uid(), ); const { user, cleanup: userCleanup } = await createUser( diff --git a/server/graphql/datasources/RuleApi.ts b/server/graphql/datasources/RuleApi.ts index 129d3f8..2eb4491 100644 --- a/server/graphql/datasources/RuleApi.ts +++ b/server/graphql/datasources/RuleApi.ts @@ -331,6 +331,14 @@ class RuleAPI { return buildGraphqlRuleParent(plain, this.graphQlRuleParentDeps); } + /** GraphQL rule parents for `Org.rules` and MRT enqueue-source payloads. */ + async getGraphQLRulesForOrg(orgId: string) { + const plains = await this.moderationConfigService.getRulesForOrg(orgId); + return plains.map((plain) => + buildGraphqlRuleParent(plain, this.graphQlRuleParentDeps), + ); + } + async createContentRule( input: GQLCreateContentRuleInput, userId: string, diff --git a/server/graphql/datasources/orgKyselyPersistence.test.ts b/server/graphql/datasources/orgKyselyPersistence.test.ts new file mode 100644 index 0000000..2b990cb --- /dev/null +++ b/server/graphql/datasources/orgKyselyPersistence.test.ts @@ -0,0 +1,238 @@ +import { faker } from '@faker-js/faker'; +import { uid } from 'uid'; + +import createOrg from '../../test/fixtureHelpers/createOrg.js'; +import { makeMockedServer } from '../../test/setupMockedServer.js'; +import { makeTestWithFixture } from '../../test/utils.js'; +import { + kyselyOrgDeleteById, + kyselyOrgFindByEmail, + kyselyOrgFindById, + kyselyOrgFindByName, + kyselyOrgInsert, + kyselyOrgUpdate, +} from './orgKyselyPersistence.js'; + +describe('orgKyselyPersistence', () => { + const testWithFixture = makeTestWithFixture(async () => { + const { deps, shutdown } = await makeMockedServer(); + const { org, cleanup: orgCleanup } = await createOrg( + { + KyselyPg: deps.KyselyPg, + ModerationConfigService: deps.ModerationConfigService, + ApiKeyService: deps.ApiKeyService, + }, + uid(), + ); + return { + deps, + org, + async cleanup() { + await orgCleanup(); + await shutdown(); + }, + }; + }); + + describe('kyselyOrgFindBy*', () => { + testWithFixture( + 'findById / findByName / findByEmail return the row when it exists', + async ({ deps, org }) => { + const byId = await kyselyOrgFindById(deps.KyselyPg, org.id); + const byName = await kyselyOrgFindByName(deps.KyselyPg, org.name); + const byEmail = await kyselyOrgFindByEmail(deps.KyselyPg, org.email); + + expect(byId).toMatchObject({ id: org.id, name: org.name }); + expect(byName).toMatchObject({ id: org.id }); + expect(byEmail).toMatchObject({ id: org.id }); + }, + ); + + testWithFixture( + 'findById / findByName / findByEmail return undefined (not null) when missing', + async ({ deps }) => { + const byId = await kyselyOrgFindById(deps.KyselyPg, `missing-${uid()}`); + const byName = await kyselyOrgFindByName( + deps.KyselyPg, + `missing-${uid()}`, + ); + const byEmail = await kyselyOrgFindByEmail( + deps.KyselyPg, + `missing-${uid()}@example.com`, + ); + + // Callers use `== null` checks, but pin `undefined` explicitly to + // catch silent drift to `null`. + expect(byId).toBeUndefined(); + expect(byName).toBeUndefined(); + expect(byEmail).toBeUndefined(); + }, + ); + }); + + describe('kyselyOrgInsert', () => { + testWithFixture( + 'throws an invariant error for malformed input (defense-in-depth)', + async ({ deps }) => { + await expect( + kyselyOrgInsert({ + db: deps.KyselyPg, + id: uid(), + email: 'not-an-email', + name: `Bad_${uid()}`, + websiteUrl: 'https://example.com', + }), + ).rejects.toThrow(/kyselyOrgInsert invariant violated: email/); + }, + ); + + testWithFixture( + 'apiKeyId is optional and defaults to NULL in the database', + async ({ deps }) => { + const id = uid(); + const inserted = await kyselyOrgInsert({ + db: deps.KyselyPg, + id, + email: faker.internet.email(), + name: `Insert_NoApiKey_${id}`, + websiteUrl: faker.internet.url(), + // apiKeyId intentionally omitted + }); + + try { + expect(inserted.id).toBe(id); + + const row = await deps.KyselyPg + .selectFrom('public.orgs') + .select(['api_key_id', 'on_call_alert_email']) + .where('id', '=', id) + .executeTakeFirstOrThrow(); + expect(row.api_key_id).toBeNull(); + expect(row.on_call_alert_email).toBeNull(); + } finally { + await kyselyOrgDeleteById(deps.KyselyPg, id); + } + }, + ); + }); + + describe('kyselyOrgUpdate', () => { + testWithFixture( + 'throws an invariant error for malformed patch (defense-in-depth)', + async ({ deps, org }) => { + await expect( + kyselyOrgUpdate(deps.KyselyPg, org.id, { + // eslint-disable-next-line no-script-url + websiteUrl: 'javascript:alert(1)', + }), + ).rejects.toThrow(/kyselyOrgUpdate invariant violated: websiteUrl/); + }, + ); + + testWithFixture( + 'returns undefined when the org does not exist', + async ({ deps }) => { + const result = await kyselyOrgUpdate( + deps.KyselyPg, + `missing-${uid()}`, + { name: 'whatever' }, + ); + expect(result).toBeUndefined(); + }, + ); + + testWithFixture( + 'empty-string websiteUrl is treated as "no change" (Sequelize parity)', + async ({ deps, org }) => { + const before = await kyselyOrgFindById(deps.KyselyPg, org.id); + expect(before).toBeDefined(); + + const updated = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + websiteUrl: '', + }); + + expect(updated).toBeDefined(); + expect(updated!.websiteUrl).toBe(before!.websiteUrl); + }, + ); + + testWithFixture( + 'null name / email / websiteUrl are skipped (only updated_at changes)', + async ({ deps, org }) => { + const before = await kyselyOrgFindById(deps.KyselyPg, org.id); + expect(before).toBeDefined(); + + const updated = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + name: null, + email: null, + websiteUrl: null, + }); + + expect(updated).toBeDefined(); + expect(updated!.name).toBe(before!.name); + expect(updated!.email).toBe(before!.email); + expect(updated!.websiteUrl).toBe(before!.websiteUrl); + }, + ); + + testWithFixture( + 'onCallAlertEmail: undefined skips, null clears, string sets', + async ({ deps, org }) => { + // Start by setting a value, then verify the three semantics. + const initial = 'oncall@example.com'; + const set = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + onCallAlertEmail: initial, + }); + expect(set!.onCallAlertEmail).toBe(initial); + + // undefined -> skip (value is preserved) + const skipped = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + name: 'unrelated-touch', + }); + expect(skipped!.onCallAlertEmail).toBe(initial); + + // null -> clear + const cleared = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + onCallAlertEmail: null, + }); + expect(cleared!.onCallAlertEmail).toBeNull(); + }, + ); + + testWithFixture( + 'updates the provided fields and bumps updated_at', + async ({ deps, org }) => { + const newName = `Renamed_${uid()}`; + const newWebsite = 'https://renamed.example.com'; + + // Read updated_at directly since it isn't part of GraphQLOrgParent. + const beforeRow = await deps.KyselyPg + .selectFrom('public.orgs') + .select(['updated_at']) + .where('id', '=', org.id) + .executeTakeFirstOrThrow(); + + // Tiny wait so the new updated_at is strictly greater. Without this, + // sub-millisecond updates can produce equal timestamps on fast hosts. + await new Promise((resolve) => setTimeout(resolve, 5)); + + const updated = await kyselyOrgUpdate(deps.KyselyPg, org.id, { + name: newName, + websiteUrl: newWebsite, + }); + + expect(updated!.name).toBe(newName); + expect(updated!.websiteUrl).toBe(newWebsite); + + const afterRow = await deps.KyselyPg + .selectFrom('public.orgs') + .select(['updated_at']) + .where('id', '=', org.id) + .executeTakeFirstOrThrow(); + expect(afterRow.updated_at.getTime()).toBeGreaterThan( + beforeRow.updated_at.getTime(), + ); + }, + ); + }); +}); diff --git a/server/graphql/datasources/orgKyselyPersistence.ts b/server/graphql/datasources/orgKyselyPersistence.ts new file mode 100644 index 0000000..0609c37 --- /dev/null +++ b/server/graphql/datasources/orgKyselyPersistence.ts @@ -0,0 +1,192 @@ +import { type Kysely } from 'kysely'; + +import { type CoreAppTablesPg } from '../../services/coreAppTables.js'; +import { + validateOrgCreateInput, + validateOrgUpdatePatch, +} from './orgValidation.js'; + +/** + * GraphQL `Org` parent shape. Field resolvers only read `id` from this; the + * remaining columns are exposed for callers that need them (e.g. the invite + * email uses `org.name`). Intentionally mirrors the columns on `public.orgs` + * that are exposed via GraphQL — no Sequelize associations. + */ +export type GraphQLOrgParent = { + id: string; + name: string; + email: string; + websiteUrl: string; + onCallAlertEmail: string | null; +}; + +/** + * Functions in this module only touch `public.orgs`; typing the param as + * `Kysely` lets callers pass either `Dependencies['KyselyPg']` + * (`Kysely`) or a more specific `Kysely` without casting. + */ +type OrgsDb = Kysely; + +function rowToGraphQLOrgParent(row: { + id: string; + name: string; + email: string; + website_url: string; + on_call_alert_email: string | null; +}): GraphQLOrgParent { + return { + id: row.id, + name: row.name, + email: row.email, + websiteUrl: row.website_url, + onCallAlertEmail: row.on_call_alert_email, + }; +} + +export async function kyselyOrgFindById( + db: OrgsDb, + id: string, +): Promise { + const row = await db + .selectFrom('public.orgs') + .selectAll() + .where('id', '=', id) + .executeTakeFirst(); + return row === undefined ? undefined : rowToGraphQLOrgParent(row); +} + +export async function kyselyOrgFindByName( + db: OrgsDb, + name: string, +): Promise { + const row = await db + .selectFrom('public.orgs') + .selectAll() + .where('name', '=', name) + .executeTakeFirst(); + return row === undefined ? undefined : rowToGraphQLOrgParent(row); +} + +export async function kyselyOrgFindByEmail( + db: OrgsDb, + email: string, +): Promise { + const row = await db + .selectFrom('public.orgs') + .selectAll() + .where('email', '=', email) + .executeTakeFirst(); + return row === undefined ? undefined : rowToGraphQLOrgParent(row); +} + +export async function kyselyOrgFindAll( + db: OrgsDb, +): Promise { + const rows = await db + .selectFrom('public.orgs') + .selectAll() + .orderBy('name', 'asc') + .execute(); + return rows.map(rowToGraphQLOrgParent); +} + +export async function kyselyOrgInsert(opts: { + db: OrgsDb; + id: string; + email: string; + name: string; + websiteUrl: string; + // `api_key_id` is nullable in the schema and was optional on the Sequelize + // model. Keep it optional here so callers that don't yet have an API key + // (e.g. legacy fixtures) can still insert. + apiKeyId?: string | null; + onCallAlertEmail?: string | null; +}): Promise { + // Defense-in-depth so non-GraphQL callers (fixtures, scripts) can't insert + // invalid rows; user-facing validation lives in `OrgAPI`. + const validation = validateOrgCreateInput({ + name: opts.name, + email: opts.email, + websiteUrl: opts.websiteUrl, + onCallAlertEmail: opts.onCallAlertEmail, + }); + if (!validation.ok) { + throw new Error( + `kyselyOrgInsert invariant violated: ${validation.failure.field}: ${validation.failure.message}`, + ); + } + + const now = new Date(); + const row = await opts.db + .insertInto('public.orgs') + .values({ + id: opts.id, + email: opts.email, + name: opts.name, + website_url: opts.websiteUrl, + api_key_id: opts.apiKeyId ?? null, + created_at: now, + updated_at: now, + on_call_alert_email: opts.onCallAlertEmail ?? null, + }) + .returningAll() + .executeTakeFirstOrThrow(); + return rowToGraphQLOrgParent(row); +} + +export async function kyselyOrgUpdate( + db: OrgsDb, + orgId: string, + patch: { + name?: string | null; + email?: string | null; + websiteUrl?: string | null; + onCallAlertEmail?: string | null; + }, +): Promise { + const validation = validateOrgUpdatePatch(patch); + if (!validation.ok) { + throw new Error( + `kyselyOrgUpdate invariant violated: ${validation.failure.field}: ${validation.failure.message}`, + ); + } + + // `onCallAlertEmail` is intentionally the only field where `null` is set + // on the row (clears the value); other fields treat `null` as skip. + const update: { + name?: string; + email?: string; + website_url?: string; + on_call_alert_email?: string | null; + updated_at: Date; + } = { updated_at: new Date() }; + + if (patch.name != null) { + update.name = patch.name; + } + if (patch.email != null) { + update.email = patch.email; + } + if (patch.websiteUrl != null && patch.websiteUrl !== '') { + update.website_url = patch.websiteUrl; + } + if (patch.onCallAlertEmail !== undefined) { + update.on_call_alert_email = patch.onCallAlertEmail; + } + + const row = await db + .updateTable('public.orgs') + .set(update) + .where('id', '=', orgId) + .returningAll() + .executeTakeFirst(); + + return row === undefined ? undefined : rowToGraphQLOrgParent(row); +} + +export async function kyselyOrgDeleteById( + db: OrgsDb, + orgId: string, +): Promise { + await db.deleteFrom('public.orgs').where('id', '=', orgId).execute(); +} diff --git a/server/graphql/datasources/orgValidation.test.ts b/server/graphql/datasources/orgValidation.test.ts new file mode 100644 index 0000000..66dec97 --- /dev/null +++ b/server/graphql/datasources/orgValidation.test.ts @@ -0,0 +1,139 @@ +import { + validateOrgCreateInput, + validateOrgUpdatePatch, +} from './orgValidation.js'; + +describe('orgValidation', () => { + describe('validateOrgCreateInput', () => { + const validInput = { + name: 'Acme', + email: 'ops@acme.example.com', + websiteUrl: 'https://acme.example.com', + }; + + test('accepts a fully valid input', () => { + expect(validateOrgCreateInput(validInput)).toEqual({ ok: true }); + }); + + test('accepts a valid optional onCallAlertEmail', () => { + expect( + validateOrgCreateInput({ + ...validInput, + onCallAlertEmail: 'oncall@acme.example.com', + }), + ).toEqual({ ok: true }); + }); + + test.each([ + ['empty', ''], + ['whitespace only', ' '], + ])('rejects name that is %s', (_label, name) => { + const result = validateOrgCreateInput({ ...validInput, name }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('name'); + } + }); + + test.each([ + ['empty', ''], + ['missing @', 'not-an-email'], + ['missing domain', 'foo@'], + ['missing tld', 'foo@bar'], + ['contains space', 'foo @bar.com'], + ])('rejects email that is %s', (_label, email) => { + const result = validateOrgCreateInput({ ...validInput, email }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('email'); + } + }); + + test.each([ + ['empty', ''], + ['not a URL', 'definitely not a url'], + // eslint-disable-next-line no-script-url + ['javascript scheme', 'javascript:alert(1)'], + ['ftp scheme', 'ftp://acme.example.com'], + ])('rejects websiteUrl that is %s', (_label, websiteUrl) => { + const result = validateOrgCreateInput({ ...validInput, websiteUrl }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('websiteUrl'); + } + }); + + test('rejects invalid onCallAlertEmail when provided', () => { + const result = validateOrgCreateInput({ + ...validInput, + onCallAlertEmail: 'not-an-email', + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('onCallAlertEmail'); + } + }); + + test('accepts onCallAlertEmail that is null or empty (optional)', () => { + expect( + validateOrgCreateInput({ ...validInput, onCallAlertEmail: null }), + ).toEqual({ ok: true }); + expect( + validateOrgCreateInput({ ...validInput, onCallAlertEmail: '' }), + ).toEqual({ ok: true }); + }); + }); + + describe('validateOrgUpdatePatch', () => { + test('accepts an empty patch (all fields undefined)', () => { + expect(validateOrgUpdatePatch({})).toEqual({ ok: true }); + }); + + test('accepts explicit-null fields (skip / clear semantics)', () => { + expect( + validateOrgUpdatePatch({ + name: null, + email: null, + websiteUrl: null, + onCallAlertEmail: null, + }), + ).toEqual({ ok: true }); + }); + + test('accepts empty-string websiteUrl (Sequelize parity: treated as skip)', () => { + expect(validateOrgUpdatePatch({ websiteUrl: '' })).toEqual({ ok: true }); + }); + + test('rejects empty / whitespace name', () => { + expect(validateOrgUpdatePatch({ name: '' }).ok).toBe(false); + expect(validateOrgUpdatePatch({ name: ' ' }).ok).toBe(false); + }); + + test('rejects malformed email', () => { + const result = validateOrgUpdatePatch({ email: 'not-an-email' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('email'); + } + }); + + test('rejects malformed websiteUrl', () => { + const result = validateOrgUpdatePatch({ + // eslint-disable-next-line no-script-url + websiteUrl: 'javascript:alert(1)', + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('websiteUrl'); + } + }); + + test('rejects malformed onCallAlertEmail (non-null string)', () => { + const result = validateOrgUpdatePatch({ onCallAlertEmail: 'nope' }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.failure.field).toBe('onCallAlertEmail'); + } + }); + }); +}); diff --git a/server/graphql/datasources/orgValidation.ts b/server/graphql/datasources/orgValidation.ts new file mode 100644 index 0000000..8e1cd03 --- /dev/null +++ b/server/graphql/datasources/orgValidation.ts @@ -0,0 +1,127 @@ +import { createRequire } from 'node:module'; +import type { IsEmailOptions } from 'validator/lib/isEmail.js'; + +import { validateUrl } from '../../utils/url.js'; + +// `validator` is CJS with UMD-style types whose `default` doesn't resolve to +// a callable under `module: NodeNext`; `createRequire` gives us `module.exports` +// directly, typed against the per-function defs that ship with `@types/validator`. +type ValidatorLib = { + isEmail: (str: string, options?: IsEmailOptions) => boolean; +}; +const validator = createRequire(import.meta.url)('validator') as ValidatorLib; + +/** + * Server-side validation for `Org` inputs, replacing the `isEmail`, `notEmpty`, + * and `validateUrl` checks that lived on the Sequelize model. + * + * Returned rather than thrown so each caller picks the right error surface: + * the data source wraps failures in `makeBadRequestError` (with a JSON pointer + * to the offending field), while persistence treats them as invariants. + * + * A follow-up move of these checks to GraphQL schema-level scalars + * (`EmailAddress`, `URL` from `graphql-scalars`) is tracked in the Sequelize + * → Kysely migration plan. + */ + +export type OrgValidationFailure = { + /** Kept stable for GraphQL JSON pointers. */ + field: 'name' | 'email' | 'websiteUrl' | 'onCallAlertEmail'; + message: string; +}; + +export type OrgValidationResult = + | { ok: true } + | { ok: false; failure: OrgValidationFailure }; + +function isEmailShape(value: string): boolean { + return validator.isEmail(value); +} + +function isNonEmptyTrimmed(value: string): boolean { + return value.trim().length > 0; +} + +function fail( + field: OrgValidationFailure['field'], + message: string, +): OrgValidationResult { + return { ok: false, failure: { field, message } }; +} + +function validateWebsiteUrlShape(value: string): OrgValidationResult { + try { + validateUrl(value); + return { ok: true }; + } catch { + return fail('websiteUrl', 'websiteUrl must be a valid http(s) URL'); + } +} + +export function validateOrgCreateInput(input: { + name: string; + email: string; + websiteUrl: string; + onCallAlertEmail?: string | null; +}): OrgValidationResult { + if (!isNonEmptyTrimmed(input.name)) { + return fail('name', 'name must not be empty'); + } + if (!isNonEmptyTrimmed(input.email) || !isEmailShape(input.email)) { + return fail('email', 'email must be a valid email address'); + } + if (!isNonEmptyTrimmed(input.websiteUrl)) { + return fail('websiteUrl', 'websiteUrl must not be empty'); + } + const websiteResult = validateWebsiteUrlShape(input.websiteUrl); + if (!websiteResult.ok) { + return websiteResult; + } + if ( + input.onCallAlertEmail != null && + input.onCallAlertEmail !== '' && + !isEmailShape(input.onCallAlertEmail) + ) { + return fail( + 'onCallAlertEmail', + 'onCallAlertEmail must be a valid email address', + ); + } + return { ok: true }; +} + +/** + * Partial-update semantics match the Sequelize model: + * - `undefined` fields are skipped + * - `websiteUrl: ''` is treated as "no change" (legacy behavior) + * - `onCallAlertEmail: null` is a meaningful value (clears the column) + */ +export function validateOrgUpdatePatch(patch: { + name?: string | null; + email?: string | null; + websiteUrl?: string | null; + onCallAlertEmail?: string | null; +}): OrgValidationResult { + if (patch.name != null && !isNonEmptyTrimmed(patch.name)) { + return fail('name', 'name must not be empty'); + } + if ( + patch.email != null && + (!isNonEmptyTrimmed(patch.email) || !isEmailShape(patch.email)) + ) { + return fail('email', 'email must be a valid email address'); + } + if (patch.websiteUrl != null && patch.websiteUrl !== '') { + const websiteResult = validateWebsiteUrlShape(patch.websiteUrl); + if (!websiteResult.ok) { + return websiteResult; + } + } + if (patch.onCallAlertEmail != null && !isEmailShape(patch.onCallAlertEmail)) { + return fail( + 'onCallAlertEmail', + 'onCallAlertEmail must be a valid email address', + ); + } + return { ok: true }; +} diff --git a/server/graphql/generated.ts b/server/graphql/generated.ts index 0fe1187..e901f36 100644 --- a/server/graphql/generated.ts +++ b/server/graphql/generated.ts @@ -7,6 +7,7 @@ import type { import { JsonObject, JsonValue } from 'type-fest'; import type { UserHistoryForGQL } from '../graphql/datasources/InvestigationApi.js'; +import type { GraphQLOrgParent } from '../graphql/datasources/orgKyselyPersistence.js'; import type { ContentItemTypeResolversParentType, ItemTypeResolversParentType, @@ -17,7 +18,6 @@ import type { } from '../graphql/modules/itemType.js'; import type { ReportingInsights } from '../graphql/modules/reporting.js'; import type { HashBank } from '../models/HashBankModel.js'; -import type { Org } from '../models/OrgModel.js'; import type { Backtest } from '../models/rules/BacktestModel.js'; import type { ItemType } from '../models/rules/ItemTypeModel.js'; import type { @@ -5795,7 +5795,7 @@ export type GQLResolversTypes = { ManualReviewQueue: ResolverTypeWrapper; ManualReviewQueueNameExistsError: ResolverTypeWrapper; MatchingBankNameExistsError: ResolverTypeWrapper; - MatchingBanks: ResolverTypeWrapper; + MatchingBanks: ResolverTypeWrapper; MatchingValues: ResolverTypeWrapper; MessageWithIpAddress: ResolverTypeWrapper< Omit & { @@ -5922,7 +5922,7 @@ export type GQLResolversTypes = { NotificationType: GQLNotificationType; OpenAiIntegrationApiCredential: ResolverTypeWrapper; OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; - Org: ResolverTypeWrapper; + Org: ResolverTypeWrapper; OrgWithEmailExistsError: ResolverTypeWrapper; OrgWithNameExistsError: ResolverTypeWrapper; PageInfo: ResolverTypeWrapper; @@ -6530,7 +6530,7 @@ export type GQLResolversParentTypes = { ManualReviewQueue: ManualReviewQueue; ManualReviewQueueNameExistsError: GQLManualReviewQueueNameExistsError; MatchingBankNameExistsError: GQLMatchingBankNameExistsError; - MatchingBanks: Org; + MatchingBanks: GraphQLOrgParent; MatchingValues: GQLMatchingValues; MessageWithIpAddress: Omit & { message: GQLResolversParentTypes['ContentItem']; @@ -6619,7 +6619,7 @@ export type GQLResolversParentTypes = { Notification: Notification; OpenAiIntegrationApiCredential: GQLOpenAiIntegrationApiCredential; OpenAiIntegrationApiCredentialInput: GQLOpenAiIntegrationApiCredentialInput; - Org: Org; + Org: GraphQLOrgParent; OrgWithEmailExistsError: GQLOrgWithEmailExistsError; OrgWithNameExistsError: GQLOrgWithNameExistsError; PageInfo: GQLPageInfo; diff --git a/server/graphql/modules/manualReviewTool.ts b/server/graphql/modules/manualReviewTool.ts index 2b4619a..03e17e9 100644 --- a/server/graphql/modules/manualReviewTool.ts +++ b/server/graphql/modules/manualReviewTool.ts @@ -1102,17 +1102,16 @@ const ContentManualReviewJobPayload: GQLContentManualReviewJobPayloadResolvers = case 'REPORT': case 'POST_ACTIONS': return { kind: enqueueSourceInfo.kind }; - case 'RULE_EXECUTION': - const org = await context.dataSources.orgAPI.getGraphQLOrgFromId( - user.orgId, - ); - const rules = await org.getRules(); + case 'RULE_EXECUTION': { + const rules = + await context.dataSources.ruleAPI.getGraphQLRulesForOrg(user.orgId); return { kind: enqueueSourceInfo.kind, rules: rules.filter((rule) => enqueueSourceInfo.rules.includes(rule.id), ), }; + } default: assertUnreachable(enqueueSourceInfo); } @@ -1318,17 +1317,16 @@ const UserManualReviewJobPayload: GQLUserManualReviewJobPayloadResolvers = { case 'REPORT': case 'POST_ACTIONS': return { kind: enqueueSourceInfo.kind }; - case 'RULE_EXECUTION': - const org = await context.dataSources.orgAPI.getGraphQLOrgFromId( - user.orgId, - ); - const rules = await org.getRules(); + case 'RULE_EXECUTION': { + const rules = + await context.dataSources.ruleAPI.getGraphQLRulesForOrg(user.orgId); return { kind: enqueueSourceInfo.kind, rules: rules.filter((rule) => enqueueSourceInfo.rules.includes(rule.id), ), }; + } default: assertUnreachable(enqueueSourceInfo); } @@ -1487,17 +1485,16 @@ const ThreadManualReviewJobPayload: GQLThreadManualReviewJobPayloadResolvers = { case 'REPORT': case 'POST_ACTIONS': return { kind: enqueueSourceInfo.kind }; - case 'RULE_EXECUTION': - const org = await context.dataSources.orgAPI.getGraphQLOrgFromId( - user.orgId, - ); - const rules = await org.getRules(); + case 'RULE_EXECUTION': { + const rules = + await context.dataSources.ruleAPI.getGraphQLRulesForOrg(user.orgId); return { kind: enqueueSourceInfo.kind, rules: rules.filter((rule) => enqueueSourceInfo.rules.includes(rule.id), ), }; + } default: assertUnreachable(enqueueSourceInfo); } @@ -1613,17 +1610,16 @@ const NcmecManualReviewJobPayload: GQLNcmecManualReviewJobPayloadResolvers = { case 'REPORT': case 'POST_ACTIONS': return { kind: enqueueSourceInfo.kind }; - case 'RULE_EXECUTION': - const org = await context.dataSources.orgAPI.getGraphQLOrgFromId( - user.orgId, - ); - const rules = await org.getRules(); + case 'RULE_EXECUTION': { + const rules = + await context.dataSources.ruleAPI.getGraphQLRulesForOrg(user.orgId); return { kind: enqueueSourceInfo.kind, rules: rules.filter((rule) => enqueueSourceInfo.rules.includes(rule.id), ), }; + } default: assertUnreachable(enqueueSourceInfo); } diff --git a/server/graphql/modules/org.ts b/server/graphql/modules/org.ts index ca74290..845bfaa 100644 --- a/server/graphql/modules/org.ts +++ b/server/graphql/modules/org.ts @@ -205,7 +205,6 @@ const Org: GQLOrgResolvers = { if (!user || user.orgId !== org.id) { throw unauthenticatedError('User required.'); } - return context.services.ModerationConfigService.getActions({ orgId: org.id, readFromReplica: true, @@ -216,7 +215,7 @@ const Org: GQLOrgResolvers = { if (!user || user.orgId !== org.id) { throw unauthenticatedError('User required.'); } - return org.getContentTypes(); + return context.dataSources.orgAPI.getSequelizeContentTypesForOrg(org.id); }, async itemTypes(org, _, context) { const user = context.getUser(); @@ -232,7 +231,7 @@ const Org: GQLOrgResolvers = { if (!user || user.orgId !== org.id) { throw unauthenticatedError('User required.'); } - return org.getUsers(); + return context.dataSources.orgAPI.getOrgUsersForGraphQL(org.id); }, async pendingInvites(org, _, context): Promise { const user = context.getUser(); @@ -253,7 +252,7 @@ const Org: GQLOrgResolvers = { if (!user || user.orgId !== org.id) { throw unauthenticatedError('User required.'); } - return org.getRules(); + return context.dataSources.ruleAPI.getGraphQLRulesForOrg(org.id); }, async routingRules(org, _, context) { const user = context.getUser(); @@ -466,10 +465,11 @@ const Org: GQLOrgResolvers = { org.id, ); }, - async usersWhoCanReviewEveryQueue(org, _, __) { - return (await org.getUsers()).filter((user) => - user.getPermissions().includes('EDIT_MRT_QUEUES'), + async usersWhoCanReviewEveryQueue(org, _, context) { + const users = await context.dataSources.orgAPI.getOrgUsersForGraphQL( + org.id, ); + return users.filter((u) => u.getPermissions().includes('EDIT_MRT_QUEUES')); }, async defaultInterfacePreferences(org, _, context) { const orgDefaults = diff --git a/server/package-lock.json b/server/package-lock.json index a8596ad..ac96821 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -94,6 +94,7 @@ "unhomoglyph": "^1.0.6", "uuid": "^8.3.2", "uuid-apikey": "^1.5.3", + "validator": "^13.15.35", "xml-js": "^1.6.11", "yargs": "^17.7.2" }, @@ -135,14 +136,6 @@ "typescript-eslint": "^8.57.2" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/@apollo/cache-control-types": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@apollo/cache-control-types/-/cache-control-types-1.0.3.tgz", @@ -2556,31 +2549,45 @@ "license": "Apache-2.0" }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "license": "Apache-2.0", "engines": { "node": ">=12.22" }, @@ -11068,20 +11075,20 @@ "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", - "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/type-utils": "8.57.2", - "@typescript-eslint/utils": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11091,20 +11098,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.57.2", + "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11115,9 +11122,9 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -11129,13 +11136,13 @@ } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11183,16 +11190,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", - "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "engines": { @@ -11204,18 +11211,40 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11225,10 +11254,27 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -11240,21 +11286,21 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11264,17 +11310,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11322,13 +11368,13 @@ } }, "node_modules/@typescript-eslint/parser/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -11421,17 +11467,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", - "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2", - "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11442,13 +11488,52 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -11460,21 +11545,21 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11484,17 +11569,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11542,13 +11627,13 @@ } }, "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -11636,16 +11721,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", - "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/typescript-estree": "8.57.2" + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11656,31 +11741,70 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", - "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2" + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", - "integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", "dev": true, "license": "MIT", "engines": { @@ -11692,21 +11816,21 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.2.tgz", - "integrity": "sha512-2MKM+I6g8tJxfSmFKOnHv2t8Sk3T6rF20A1Puk0svLK+uVapDZB/4pfAeB7nE83uAZrU6OxW+HmOd5wHVdXwXA==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.57.2", - "@typescript-eslint/tsconfig-utils": "8.57.2", - "@typescript-eslint/types": "8.57.2", - "@typescript-eslint/visitor-keys": "8.57.2", + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.4.0" + "ts-api-utils": "^2.5.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -11716,17 +11840,17 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <6.0.0" + "typescript": ">=4.8.4 <6.1.0" } }, "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": { - "version": "8.57.2", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", - "integrity": "sha512-zhahknjobV2FiD6Ee9iLbS7OV9zi10rG26odsQdfBO/hjSzUQbkIYgda+iNKK1zNiW2ey+Lf8MU5btN17V3dUw==", + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -11774,13 +11898,13 @@ } }, "node_modules/@typescript-eslint/utils/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -13217,7 +13341,8 @@ "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==" + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT" }, "node_modules/deepmerge": { "version": "4.3.1", @@ -13832,6 +13957,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-es/-/eslint-plugin-es-3.0.1.tgz", "integrity": "sha512-GUmAsJaN4Fc7Gbtl8uOBlayo2DqhwWvEzykMHSCZHU3XdJ+NSzzZcVhXh3VxX5icqQ+oQdIEawXX8xkR3mIFmQ==", "dev": true, + "license": "MIT", "dependencies": { "eslint-utils": "^2.0.0", "regexpp": "^3.0.0" @@ -14184,9 +14310,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -14262,6 +14388,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" }, @@ -14571,7 +14698,8 @@ "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT" }, "node_modules/fast-safe-stringify": { "version": "2.1.1", @@ -14744,6 +14872,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "license": "MIT", "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" @@ -15187,6 +15316,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", "dependencies": { "is-glob": "^4.0.3" }, @@ -17422,7 +17552,8 @@ "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==" + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -17545,6 +17676,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" @@ -17564,6 +17696,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "license": "MIT", "dependencies": { "p-locate": "^5.0.0" }, @@ -17629,7 +17762,8 @@ "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "license": "MIT" }, "node_modules/lodash.once": { "version": "4.1.1", @@ -18343,16 +18477,17 @@ } }, "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "license": "MIT", "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" }, "engines": { "node": ">= 0.8.0" @@ -18394,6 +18529,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "license": "MIT", "dependencies": { "p-limit": "^3.0.2" }, @@ -18408,6 +18544,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -18422,6 +18559,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", "engines": { "node": ">=10" }, @@ -18912,6 +19050,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "license": "MIT", "engines": { "node": ">= 0.8.0" } @@ -19253,6 +19392,7 @@ "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz", "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -20838,6 +20978,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" }, @@ -21018,6 +21159,103 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.2.tgz", + "integrity": "sha512-NZZgp0Fm2IkD+La5PR81sd+g+8oS6JwJje+aRWsDocxHkjyRw0J5L5ZTlN3LI1LlOcGL7ph3eaIUmTXMIjLk0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/type-utils": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/parser": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.2.tgz", + "integrity": "sha512-30ScMRHIAD33JJQkgfGW1t8CURZtjc2JpTrq5n2HFhOefbAhb7ucc7xJwdWcrEtqUIYJ73Nybpsggii6GtAHjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.2.tgz", + "integrity": "sha512-snZKH+W4WbWkrBqj4gUNRIGb/jipDW3qMqVJ4C9rzdFc+wLwruxk+2a5D+uoFcKPAqyqEnSb4l2ULuZf95eSkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/visitor-keys": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/type-utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.2.tgz", + "integrity": "sha512-Co6ZCShm6kIbAM/s+oYVpKFfW7LBc6FXoPXjTRQ449PPNBY8U0KZXuevz5IFuuUj2H9ss40atTaf9dlGLzbWZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2", + "@typescript-eslint/utils": "8.57.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/types": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.2.tgz", @@ -21060,6 +21298,30 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/typescript-eslint/node_modules/@typescript-eslint/utils": { + "version": "8.57.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.2.tgz", + "integrity": "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.2", + "@typescript-eslint/types": "8.57.2", + "@typescript-eslint/typescript-estree": "8.57.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, "node_modules/typescript-eslint/node_modules/@typescript-eslint/visitor-keys": { "version": "8.57.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.2.tgz", @@ -21114,6 +21376,16 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/typescript-eslint/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/typescript-eslint/node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -21321,9 +21593,9 @@ } }, "node_modules/validator": { - "version": "13.15.26", - "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", - "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", "license": "MIT", "engines": { "node": ">= 0.10" @@ -21493,6 +21765,15 @@ "@types/node": "*" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/server/package.json b/server/package.json index d1e406e..82c0085 100644 --- a/server/package.json +++ b/server/package.json @@ -108,6 +108,7 @@ "unhomoglyph": "^1.0.6", "uuid": "^8.3.2", "uuid-apikey": "^1.5.3", + "validator": "^13.15.35", "xml-js": "^1.6.11", "yargs": "^17.7.2" }, diff --git a/server/routes/content/ContentRoutes.test.ts b/server/routes/content/ContentRoutes.test.ts index 2f4ee1d..eeedb37 100644 --- a/server/routes/content/ContentRoutes.test.ts +++ b/server/routes/content/ContentRoutes.test.ts @@ -20,10 +20,12 @@ describe('POST Content', () => { let request: Awaited>['request'], shutdown: Awaited>['shutdown'], apiKey: Awaited>['apiKey'], + orgCleanup: Awaited>['cleanup'], models: Dependencies['Sequelize'], ModerationConfigService: Dependencies['ModerationConfigService'], ApiKeyService: Dependencies['ApiKeyService'], - analytics: Dependencies['DataWarehouseAnalytics']; + analytics: Dependencies['DataWarehouseAnalytics'], + KyselyPg: Dependencies['KyselyPg']; beforeAll(async () => { ({ @@ -34,15 +36,14 @@ describe('POST Content', () => { DataWarehouseAnalytics: analytics, ModerationConfigService, ApiKeyService, + KyselyPg, }, } = await makeMockedServer()); - const { User, Org } = models; + const { User } = models; - ({ apiKey } = await createOrg( - { Org }, - ModerationConfigService, - ApiKeyService, + ({ apiKey, cleanup: orgCleanup } = await createOrg( + { KyselyPg, ModerationConfigService, ApiKeyService }, orgId, )); @@ -92,8 +93,8 @@ describe('POST Content', () => { }); afterAll(async () => { - const { Org, User } = models; - await Org.destroy({ where: { id: orgId } }); + const { User } = models; + await orgCleanup(); await ModerationConfigService.deleteItemType({ orgId, itemTypeId: contentType1.id, diff --git a/server/routes/gdpr/gdprRoutes.test.ts b/server/routes/gdpr/gdprRoutes.test.ts index 1847927..0910343 100644 --- a/server/routes/gdpr/gdprRoutes.test.ts +++ b/server/routes/gdpr/gdprRoutes.test.ts @@ -13,24 +13,21 @@ describe('POST /gdrp/delete', () => { let request: Awaited>['request'], shutdown: Awaited>['shutdown'], apiKey: Awaited>['apiKey'], + orgCleanup: Awaited>['cleanup'], ApiKeyService: Dependencies['ApiKeyService'], ModerationConfigService: Dependencies['ModerationConfigService'], - models: Dependencies['Sequelize']; + KyselyPg: Dependencies['KyselyPg']; beforeAll(async () => { try { ({ request, shutdown, - deps: { Sequelize: models, ModerationConfigService, ApiKeyService }, + deps: { ModerationConfigService, ApiKeyService, KyselyPg }, } = await makeMockedServer()); - const { Org } = models; - - ({ apiKey } = await createOrg( - { Org }, - ModerationConfigService, - ApiKeyService, + ({ apiKey, cleanup: orgCleanup } = await createOrg( + { KyselyPg, ModerationConfigService, ApiKeyService }, orgId, )); @@ -54,8 +51,7 @@ describe('POST /gdrp/delete', () => { }); afterAll(async () => { - const { Org } = models; - await Org.destroy({ where: { id: orgId } }); + await orgCleanup(); await shutdown(); }); diff --git a/server/routes/items/ItemRoutes.test.ts b/server/routes/items/ItemRoutes.test.ts index d2d74a1..bcc7dea 100644 --- a/server/routes/items/ItemRoutes.test.ts +++ b/server/routes/items/ItemRoutes.test.ts @@ -15,10 +15,12 @@ describe('POST Items', () => { let request: Awaited>['request'], shutdown: Awaited>['shutdown'], apiKey: Awaited>['apiKey'], + orgCleanup: Awaited>['cleanup'], models: Dependencies['Sequelize'], ModerationConfigService: Dependencies['ModerationConfigService'], ApiKeyService: Dependencies['ApiKeyService'], - analytics: Dependencies['DataWarehouseAnalytics']; + analytics: Dependencies['DataWarehouseAnalytics'], + KyselyPg: Dependencies['KyselyPg']; beforeAll(async () => { ({ @@ -29,15 +31,14 @@ describe('POST Items', () => { DataWarehouseAnalytics: analytics, ModerationConfigService, ApiKeyService, + KyselyPg, }, } = await makeMockedServer()); - const { User, Org } = models; + const { User } = models; - ({ apiKey } = await createOrg( - { Org }, - ModerationConfigService, - ApiKeyService, + ({ apiKey, cleanup: orgCleanup } = await createOrg( + { KyselyPg, ModerationConfigService, ApiKeyService }, orgId, )); @@ -73,8 +74,8 @@ describe('POST Items', () => { }); afterAll(async () => { - const { Org, User } = models; - await Org.destroy({ where: { id: orgId } }); + const { User } = models; + await orgCleanup(); await ModerationConfigService.deleteItemType({ orgId, itemTypeId: contentType.id, diff --git a/server/routes/policies/PoliciesRoutes.test.ts b/server/routes/policies/PoliciesRoutes.test.ts index 37b2a52..29fbbfa 100644 --- a/server/routes/policies/PoliciesRoutes.test.ts +++ b/server/routes/policies/PoliciesRoutes.test.ts @@ -13,32 +13,35 @@ describe('GET policies', () => { let request: Awaited>['request'], shutdown: Awaited>['shutdown'], apiKey: Awaited>['apiKey'], + orgCleanup: Awaited>['cleanup'], models: Dependencies['Sequelize'], ModerationConfigService: Dependencies['ModerationConfigService'], - ApiKeyService: Dependencies['ApiKeyService']; + ApiKeyService: Dependencies['ApiKeyService'], + KyselyPg: Dependencies['KyselyPg']; beforeAll(async () => { ({ request, shutdown, - deps: { Sequelize: models, ModerationConfigService, ApiKeyService }, + deps: { + Sequelize: models, + ModerationConfigService, + ApiKeyService, + KyselyPg, + }, } = await makeMockedServer()); - const { Org } = models; - - ({ apiKey } = await createOrg( - { Org }, - ModerationConfigService, - ApiKeyService, + ({ apiKey, cleanup: orgCleanup } = await createOrg( + { KyselyPg, ModerationConfigService, ApiKeyService }, orgId, )); }); afterAll(async () => { - const { Org, Policy } = models; + const { Policy } = models; await Policy.destroy({ where: { id: policyId1 } }); await Policy.destroy({ where: { id: policyId2 } }); - await Org.destroy({ where: { id: orgId } }); + await orgCleanup(); await shutdown(); }); diff --git a/server/routes/reporting/ReportingRoutes.test.ts b/server/routes/reporting/ReportingRoutes.test.ts index b7978b5..265d92b 100644 --- a/server/routes/reporting/ReportingRoutes.test.ts +++ b/server/routes/reporting/ReportingRoutes.test.ts @@ -17,7 +17,8 @@ describe('POST Report', () => { deps: Awaited>['deps'], request: Awaited>['request'], shutdown: Awaited>['shutdown'], - apiKey: Awaited>['apiKey']; + apiKey: Awaited>['apiKey'], + orgCleanup: Awaited>['cleanup']; const getBulkWriteMock = () => deps.DataWarehouseAnalytics.bulkWrite as jest.MockedFunction< @@ -29,10 +30,12 @@ describe('POST Report', () => { models = deps.Sequelize; - ({ apiKey } = await createOrg( - models, - deps.ModerationConfigService, - deps.ApiKeyService, + ({ apiKey, cleanup: orgCleanup } = await createOrg( + { + KyselyPg: deps.KyselyPg, + ModerationConfigService: deps.ModerationConfigService, + ApiKeyService: deps.ApiKeyService, + }, orgId, )); const userType = await deps.ModerationConfigService.createUserType(orgId, { @@ -119,8 +122,8 @@ describe('POST Report', () => { }); afterAll(async () => { - const { Org, User, ItemType } = models; - await Org.destroy({ where: { id: orgId } }); + const { User, ItemType } = models; + await orgCleanup(); await ItemType.destroy({ where: { id: contentTypeId } }); await ItemType.destroy({ where: { id: userTypeId } }); await ItemType.destroy({ where: { id: threadTypeId } }); diff --git a/server/routes/user_scores/UserScoresRoutes.test.ts b/server/routes/user_scores/UserScoresRoutes.test.ts index da8cb19..96d0ed5 100644 --- a/server/routes/user_scores/UserScoresRoutes.test.ts +++ b/server/routes/user_scores/UserScoresRoutes.test.ts @@ -11,13 +11,11 @@ describe('GET policies', () => { const { request, shutdown, - deps: { Sequelize: models, ModerationConfigService, ApiKeyService }, + deps: { ModerationConfigService, ApiKeyService, KyselyPg }, } = await makeMockedServer(); const { org, apiKey, cleanup: orgCleanup } = await createOrg( - models, - ModerationConfigService, - ApiKeyService, + { KyselyPg, ModerationConfigService, ApiKeyService }, uid(), ); const { itemTypes, cleanup } = await createUserItemTypes({ diff --git a/server/services/itemInvestigationService/itemInvestigationService.test.ts b/server/services/itemInvestigationService/itemInvestigationService.test.ts index 8e933c6..1900760 100644 --- a/server/services/itemInvestigationService/itemInvestigationService.test.ts +++ b/server/services/itemInvestigationService/itemInvestigationService.test.ts @@ -42,9 +42,11 @@ describe('Item Investigation Service', () => { const dummyOrgId = uid(); await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, dummyOrgId, ); @@ -123,9 +125,11 @@ describe('Item Investigation Service', () => { const dummyOrgId = uid(); await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, dummyOrgId, ); diff --git a/server/services/manualReviewToolService/modules/CommentOperations.test.ts b/server/services/manualReviewToolService/modules/CommentOperations.test.ts index a75c71c..26091f1 100644 --- a/server/services/manualReviewToolService/modules/CommentOperations.test.ts +++ b/server/services/manualReviewToolService/modules/CommentOperations.test.ts @@ -16,9 +16,11 @@ describe('CommentOperations', () => { // Create test org const orgId = uuidv1(); const { cleanup: orgCleanup } = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, orgId, ); diff --git a/server/services/manualReviewToolService/modules/JobRouting.test.ts b/server/services/manualReviewToolService/modules/JobRouting.test.ts index 13badda..9ba7376 100644 --- a/server/services/manualReviewToolService/modules/JobRouting.test.ts +++ b/server/services/manualReviewToolService/modules/JobRouting.test.ts @@ -21,12 +21,13 @@ import { SignalType } from '../../signalsService/index.js'; describe('JobRouting tests', () => { const jobRoutingTestWithFixtures = makeTestWithFixture(async () => { const { container } = await getBottle(); - const { Org } = container.Sequelize; const manualReviewToolService = container.ManualReviewToolService; const { org, cleanup: orgCleanup } = await createOrg( - { Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); const userId = uid(); diff --git a/server/services/manualReviewToolService/modules/QueueOperations.test.ts b/server/services/manualReviewToolService/modules/QueueOperations.test.ts index 1839b35..5a67819 100644 --- a/server/services/manualReviewToolService/modules/QueueOperations.test.ts +++ b/server/services/manualReviewToolService/modules/QueueOperations.test.ts @@ -36,9 +36,11 @@ describe('QueueOperations', () => { const container = (await getBottle()).container; const { org, cleanup: orgCleanup } = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); diff --git a/server/services/moderationConfigService/moderationConfigService.test.ts b/server/services/moderationConfigService/moderationConfigService.test.ts index 4df9c06..3cdc257 100644 --- a/server/services/moderationConfigService/moderationConfigService.test.ts +++ b/server/services/moderationConfigService/moderationConfigService.test.ts @@ -75,6 +75,7 @@ describe('ModerationConfigService', () => { ] satisfies Policy[]; const dummyOrgId = uid(); + let dummyOrgCleanup: () => Promise; const dummySchema = [ { name: 'fakeField', type: 'STRING', required: false, container: null }, ] as const; @@ -118,19 +119,22 @@ describe('ModerationConfigService', () => { ); const createOrgResult = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, dummyOrgId, ); defaultUserItemType = createOrgResult.defaultUserItemType; + dummyOrgCleanup = createOrgResult.cleanup; allCreatedItemTypes = [...allCreatedItemTypes, defaultUserItemType]; }); afterAll(async () => { const { Sequelize: models } = (await getBottle()).container; - await models.Org.destroy({ where: { id: dummyOrgId } }); + await dummyOrgCleanup(); await Promise.all([ container.KyselyPg.destroy(), @@ -193,9 +197,11 @@ describe('ModerationConfigService', () => { describe('#getRuleByIdAndOrg', () => { const testWithRuleRow = makeTestWithFixture(async () => { const { org, cleanup: orgCleanup } = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); const { user, cleanup: userCleanup } = await createUser( @@ -248,9 +254,11 @@ describe('ModerationConfigService', () => { 'returns null when the org id does not match (IDOR guard)', async ({ ruleId }) => { const { org: otherOrg, cleanup: otherOrgCleanup } = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); try { @@ -702,9 +710,11 @@ describe('ModerationConfigService', () => { 'should throw NotFound when called with the wrong org', async ({ action }) => { const otherOrg = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); try { @@ -894,9 +904,11 @@ describe('ModerationConfigService', () => { 'should return false when called with the wrong org and leave the row intact', async ({ action }) => { const otherOrg = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); try { @@ -1078,9 +1090,11 @@ describe('ModerationConfigService', () => { // applies-to-all rows (they'd otherwise leak across orgs since the // ANY(...) predicate alone has no tenant scope). const otherOrg = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); try { @@ -1141,9 +1155,11 @@ describe('ModerationConfigService', () => { 'should not return actions when called with a different org', async ({ rule }) => { const otherOrg = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); try { @@ -1177,9 +1193,11 @@ describe('ModerationConfigService', () => { describe('Mutations', () => { const testWithUserAndOrg = makeTestWithFixture(async () => { const { org, cleanup: orgCleanup } = await createOrg( - { Org: container.Sequelize.Org }, - container.ModerationConfigService, - container.ApiKeyService, + { + KyselyPg: container.KyselyPg, + ModerationConfigService: container.ModerationConfigService, + ApiKeyService: container.ApiKeyService, + }, uid(), ); diff --git a/server/services/moderationConfigService/moderationConfigService.ts b/server/services/moderationConfigService/moderationConfigService.ts index 5f5920c..5734e2c 100644 --- a/server/services/moderationConfigService/moderationConfigService.ts +++ b/server/services/moderationConfigService/moderationConfigService.ts @@ -332,6 +332,13 @@ export class ModerationConfigService implements ReturnsModerationConfigTypes { return this.ruleReadOps.getRuleByIdAndOrg(ruleId, orgId, opts); } + async getRulesForOrg( + orgId: string, + opts?: { readFromReplica?: boolean }, + ): Promise { + return this.ruleReadOps.getRulesForOrg(orgId, opts); + } + async findEnabledUserRules(): Promise { return this.ruleReadOps.findEnabledUserRules(); } diff --git a/server/services/moderationConfigService/modules/RuleReadOperations.ts b/server/services/moderationConfigService/modules/RuleReadOperations.ts index 810aed8..7e2a2e1 100644 --- a/server/services/moderationConfigService/modules/RuleReadOperations.ts +++ b/server/services/moderationConfigService/modules/RuleReadOperations.ts @@ -146,6 +146,27 @@ export default class RuleReadOperations { return rowToPlainRuleWithLatest(row); } + /** + * All rules for an org (latest version string), for GraphQL org.rules and + * similar list surfaces. Not filtered by enabled status. + */ + async getRulesForOrg( + orgId: string, + opts?: { readFromReplica?: boolean }, + ): Promise { + const readFromReplica = opts?.readFromReplica ?? true; + const pg = readFromReplica ? this.pgQueryReplica : this.pgQuery; + const rows = (await pg + .selectFrom('public.rules as r') + .leftJoin('public.rules_latest_versions as rlv', 'rlv.rule_id', 'r.id') + .select(ruleSelect) + .where('r.org_id', '=', orgId) + .orderBy('r.name', 'asc') + .execute()) as RuleRow[]; + + return rows.map(rowToPlainRuleWithLatest); + } + async findEnabledUserRules() { const today = String(getUtcDateOnlyString()); const rows = (await this.pgQueryReplica diff --git a/server/services/ruleAnomalyDetectionService/detectRulePassRateAnomaliesJob.test.ts b/server/services/ruleAnomalyDetectionService/detectRulePassRateAnomaliesJob.test.ts index f520910..64075b0 100644 --- a/server/services/ruleAnomalyDetectionService/detectRulePassRateAnomaliesJob.test.ts +++ b/server/services/ruleAnomalyDetectionService/detectRulePassRateAnomaliesJob.test.ts @@ -90,21 +90,24 @@ describe('Detect Rule Anomalies', () => { Sequelize: models, ModerationConfigService, ApiKeyService, + KyselyPg, } = (await getBottle()).container; // make some fake rules (w/ stable ids so we can match them in a snapshot) // in different initial alarm statuses, to test all 9 combinations [i.e., // starting and ending at one of (OK, ALARM, or INSUFFICENT_DATA), where // the start and end states can be the same]. - const { org } = await createOrg( - models, - ModerationConfigService, - ApiKeyService, - ); - const { org: org2 } = await createOrg( - models, + const { org, cleanup: orgCleanup } = await createOrg({ + KyselyPg, ModerationConfigService, ApiKeyService, + }); + const { org: org2, cleanup: org2Cleanup } = await createOrg( + { + KyselyPg, + ModerationConfigService, + ApiKeyService, + }, undefined, { onCallAlertEmail: 'test@gmail.com' }, ); @@ -208,7 +211,8 @@ describe('Detect Rule Anomalies', () => { deleteMockData = async () => { await Promise.all(fakeRules.map(async (it) => it.destroy())); await Promise.all([ruleOwner.destroy(), ruleOwner2.destroy()]); - await Promise.all([org.destroy(), org2.destroy()]); + await orgCleanup(); + await org2Cleanup(); await models.sequelize.close(); }; /* eslint-enable functional/immutable-data */ diff --git a/server/services/signalsService/signals/aggregation/AggregationSignal.test.ts b/server/services/signalsService/signals/aggregation/AggregationSignal.test.ts index 018e27d..7062b0c 100644 --- a/server/services/signalsService/signals/aggregation/AggregationSignal.test.ts +++ b/server/services/signalsService/signals/aggregation/AggregationSignal.test.ts @@ -27,12 +27,11 @@ describe('AggregationSignal', () => { RuleAPIDataSource, ActionAPIDataSource, ApiKeyService, + KyselyPg, } = deps; const { org, cleanup: orgCleanup } = await createOrg( - models, - ModerationConfigService, - ApiKeyService, + { KyselyPg, ModerationConfigService, ApiKeyService }, uid(), ); diff --git a/server/test/fixtureHelpers/createOrg.ts b/server/test/fixtureHelpers/createOrg.ts index 6e394f6..e35f770 100644 --- a/server/test/fixtureHelpers/createOrg.ts +++ b/server/test/fixtureHelpers/createOrg.ts @@ -1,41 +1,51 @@ import { faker } from '@faker-js/faker'; import { uid } from 'uid'; +import { + kyselyOrgDeleteById, + kyselyOrgInsert, +} from '../../graphql/datasources/orgKyselyPersistence.js'; import { type Dependencies } from '../../iocContainer/index.js'; import { logErrorAndThrow } from '../utils.js'; -export default async function ( - models: Pick, - moderationConfigService: Dependencies['ModerationConfigService'], - apiKeyService: Dependencies['ApiKeyService'], +export default async function createOrg( + deps: Pick< + Dependencies, + 'KyselyPg' | 'ModerationConfigService' | 'ApiKeyService' + >, id?: string, extra: { onCallAlertEmail?: string; } = {}, ) { const orgId = id ?? uid(); - const org = await models.Org.create({ + + const org = await kyselyOrgInsert({ + db: deps.KyselyPg, id: orgId, name: `Dummy_Company_Name_${orgId}`, email: faker.internet.email(), websiteUrl: faker.internet.url(), - onCallAlertEmail: extra.onCallAlertEmail ?? undefined, + onCallAlertEmail: extra.onCallAlertEmail ?? null, }).catch(logErrorAndThrow); - const { apiKey } = await apiKeyService - .createApiKey(orgId, `Dummy_Company_Name_${orgId}_Key`, null, null) - .catch(logErrorAndThrow); + const { apiKey } = await deps.ApiKeyService.createApiKey( + orgId, + `Dummy_Company_Name_${orgId}_Key`, + null, + null, + ).catch(logErrorAndThrow); - const defaultUserItemType = await moderationConfigService - .createDefaultUserType(orgId) - .catch(logErrorAndThrow); + const defaultUserItemType = await deps.ModerationConfigService.createDefaultUserType( + orgId, + ).catch(logErrorAndThrow); return { org, apiKey, defaultUserItemType, async cleanup() { - await org.destroy(); + await kyselyOrgDeleteById(deps.KyselyPg, orgId); }, }; }