import crypto from 'node:crypto'; import { URL } from 'node:url'; import { type Exception } from '@opentelemetry/api'; import { DataSource } from 'apollo-datasource'; import { uid } from 'uid'; import { inject, type Dependencies } from '../../iocContainer/index.js'; import { CoopEmailAddress } from '../../services/sendEmailService/index.js'; import { b64EncodeArrayBuffer } from '../../utils/encoding.js'; import { CoopError, ErrorType, type ErrorInstanceData, isCoopErrorOfType, } from '../../utils/errors.js'; import { WEEK_MS } from '../../utils/time.js'; import { type GQLInviteUserInput, type GQLMutationCreateOrgArgs, type GQLRequestDemoInput, } from '../generated.js'; class OrgAPI extends DataSource { 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'], private readonly userManagementService: Dependencies['UserManagementService'], private readonly config: Dependencies['ConfigService'], private readonly orgSettingsService: Dependencies['OrgSettingsService'], private readonly manualReviewToolService: Dependencies['ManualReviewToolService'], ) { super(); } async createOrg(params: GQLMutationCreateOrgArgs) { const { email, name, website } = params.input; const existingOrgByName = await this.sequelize.Org.findOne({ where: { name }, }); if (existingOrgByName != null) { throw makeOrgNameExistsError({ shouldErrorSpan: true }); } const existingOrgByEmail = await this.sequelize.Org.findOne({ where: { email }, }); if (existingOrgByEmail != null) { throw makeOrgEmailExistsError({ shouldErrorSpan: true }); } const id = uid(); // Create api key before inserting the org, so we don't have to worry about // the possibility of orgs existing without an api key. // TODO: if org fails to save later, delete orphaned api key. const { record } = await this.apiKeyService.createApiKey( id, 'Main API Key', 'Primary API key for organization', null ); // TODO: if org fails to save later, delete orphaned signing key pair await this.signingKeyPairService.createAndStoreSigningKeys(id); try { const org = await this.sequelize.Org.create({ id, email, name, websiteUrl: website, apiKeyId: record.id, }); await Promise.all([ // This should ideally be done in one transaction, but we can update // this after we move off of sequelize this.moderationConfigService.createDefaultUserType(id), this.orgCreationLogger.logOrgCreated(id, name, email, website), this.userManagementService.upsertOrgDefaultUserInterfaceSettings({ orgId: id, }), this.orgSettingsService.upsertOrgDefaultSettings({ orgId: id }), this.manualReviewToolService.upsertDefaultSettings({ orgId: id }), ]); return org; } catch (e) { const activeSpan = this.tracer.getActiveSpan(); if (activeSpan?.isRecording()) { activeSpan.recordException(e as Exception); } throw e; } } // 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 token = await this.userManagementService.createInviteUserToken({ email, role, orgId, }); const url = new URL(`${this.config.uiUrl}/signup/${token}`); const msg = { to: email, from: CoopEmailAddress.NoReply, subject: "You've been invited to join your team on Coop!", html: `Hi, and welcome to Coop! Your admin has invited you to join the ${org.name} Coop team.

Click on this link to get started! The link expires in 24 hours, so please make sure to sign up soon.

Best,
Coop Support Team`, }; try { await this.sendEmail(msg); } catch (error: unknown) { // Even if email fails, return the token so it can be copied // eslint-disable-next-line no-console console.warn('Failed to send invite email, but token was created:', error); } return token; } async getInviteUserToken(tokenString: string) { const token = await this.userManagementService.getInviteUserToken({ token: tokenString, }); // NB: if the db query above returns in a time proportional to the number // of matching characters at the start of the tokenString, then this code // is vulnerable to a timing attack. But we don't care, and can't do much // about it, for right now. // eslint-disable-next-line security/detect-possible-timing-attacks if (token == null) { throw makeInviteUserTokenMissingError({ shouldErrorSpan: true }); } if (Date.now() - new Date(token.createdAt).getTime() > 2 * WEEK_MS) { throw makeInviteUserTokenExpiredError({ shouldErrorSpan: true }); } return token; } async requestDemo(input: GQLRequestDemoInput) { const { email, company, website, interests, ref, isFromGoogleAds } = input; const msg = { to: CoopEmailAddress.Support, from: CoopEmailAddress.NoReply, subject: '[URGENT] Demo Request', text: `A new potential user has requested a Coop demo.\n\nEmail address: ${email}\n\nCompany name: ${company}\n\nCompany website: ${website}\n\nInterests: ${interests.join( ', ', )} \n\nRef: ${ref} \n\nIs from Google Ads: ${isFromGoogleAds}`, }; try { await this.sendEmail(msg); } catch (error: unknown) { return false; } return true; } async getGraphQLOrgFromId(id: string) { return this.sequelize.Org.findByPk(id, { rejectOnEmpty: true }); } async getAllGraphQLOrgs() { return this.sequelize.Org.findAll(); } async updateOrgInfo( orgId: string, input: { name?: string | null; email?: string | null; websiteUrl?: string | null; onCallAlertEmail?: string | null; }, ) { const org = await this.sequelize.Org.findByPk(orgId); if (!org) { throw new Error('Organization not found'); } 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; } await org.save(); return org; } // TODO: ApiKeyService should maybe be its own dataSource, // or just an object on context? async getActivatedApiKeyForOrg(orgId: string) { const apiKeyRecord = await this.apiKeyService.getActiveApiKeyForOrg(orgId); if (!apiKeyRecord) { return false; } return { key: apiKeyRecord.keyHash, metadata: { name: apiKeyRecord.name, description: apiKeyRecord.description ?? '', }, }; } /** * Returns the org's webhook public signing key as PEM. If no key exists yet * (e.g. org created before this feature), we create and persist one once. */ async getPublicSigningKeyPem(orgId: string) { let key: CryptoKey; try { key = await this.signingKeyPairService.getSignatureVerificationInfo( orgId, ); } catch (error) { if (isCoopErrorOfType(error, 'SigningKeyPairNotFound')) { key = await this.signingKeyPairService.createAndStoreSigningKeys(orgId); } else { throw error; } } const exported = await crypto.subtle.exportKey('spki', key); const exportedAsBase64 = b64EncodeArrayBuffer(exported); return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; } /** * Rotates the webhook signing key for the org: generates a new key pair, * overwrites storage, invalidates cache, and returns the new public key as PEM. */ async rotateWebhookSigningKey(orgId: string): Promise { const publicKey = await this.signingKeyPairService.rotateSigningKeys(orgId); const exported = await crypto.subtle.exportKey('spki', publicKey); const exportedAsBase64 = b64EncodeArrayBuffer(exported); return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; } } export type OrgErrorType = | 'OrgWithEmailExistsError' | 'OrgWithNameExistsError' | 'InviteUserTokenExpiredError' | 'InviteUserTokenMissingError'; export const makeOrgEmailExistsError = (data: ErrorInstanceData) => new CoopError({ status: 409, type: [ErrorType.UniqueViolation], title: 'An org with this email already exists', name: 'OrgWithEmailExistsError', ...data, }); export const makeOrgNameExistsError = (data: ErrorInstanceData) => new CoopError({ status: 409, type: [ErrorType.UniqueViolation], title: 'An org with this name already exists', name: 'OrgWithNameExistsError', ...data, }); export const makeInviteUserTokenExpiredError = (data: ErrorInstanceData) => new CoopError({ status: 403, type: [ErrorType.Unauthorized], title: 'Invite token expired', name: 'InviteUserTokenExpiredError', ...data, }); export const makeInviteUserTokenMissingError = (data: ErrorInstanceData) => new CoopError({ status: 401, type: [ErrorType.Unauthorized], title: 'Invite token missing', name: 'InviteUserTokenMissingError', ...data, }); export default inject( [ 'OrgCreationLogger', 'ApiKeyService', 'sendEmail', 'Sequelize', 'SigningKeyPairService', 'Tracer', 'ModerationConfigService', 'UserManagementService', 'ConfigService', 'OrgSettingsService', 'ManualReviewToolService', ], OrgAPI, ); export type { OrgAPI };