Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 324 lines 10 kB view raw
1import crypto from 'node:crypto'; 2import { URL } from 'node:url'; 3import { type Exception } from '@opentelemetry/api'; 4import { DataSource } from 'apollo-datasource'; 5import { uid } from 'uid'; 6 7import { inject, type Dependencies } from '../../iocContainer/index.js'; 8import { CoopEmailAddress } from '../../services/sendEmailService/index.js'; 9import { b64EncodeArrayBuffer } from '../../utils/encoding.js'; 10import { 11 CoopError, 12 ErrorType, 13 type ErrorInstanceData, 14 isCoopErrorOfType, 15} from '../../utils/errors.js'; 16import { WEEK_MS } from '../../utils/time.js'; 17import { 18 type GQLInviteUserInput, 19 type GQLMutationCreateOrgArgs, 20 type GQLRequestDemoInput, 21} from '../generated.js'; 22 23class OrgAPI extends DataSource { 24 constructor( 25 private readonly orgCreationLogger: Dependencies['OrgCreationLogger'], 26 private readonly apiKeyService: Dependencies['ApiKeyService'], 27 private readonly sendEmail: Dependencies['sendEmail'], 28 private readonly sequelize: Dependencies['Sequelize'], 29 private readonly signingKeyPairService: Dependencies['SigningKeyPairService'], 30 private readonly tracer: Dependencies['Tracer'], 31 private readonly moderationConfigService: Dependencies['ModerationConfigService'], 32 private readonly userManagementService: Dependencies['UserManagementService'], 33 private readonly config: Dependencies['ConfigService'], 34 private readonly orgSettingsService: Dependencies['OrgSettingsService'], 35 private readonly manualReviewToolService: Dependencies['ManualReviewToolService'], 36 ) { 37 super(); 38 } 39 40 async createOrg(params: GQLMutationCreateOrgArgs) { 41 const { email, name, website } = params.input; 42 const existingOrgByName = await this.sequelize.Org.findOne({ 43 where: { name }, 44 }); 45 if (existingOrgByName != null) { 46 throw makeOrgNameExistsError({ shouldErrorSpan: true }); 47 } 48 const existingOrgByEmail = await this.sequelize.Org.findOne({ 49 where: { email }, 50 }); 51 52 if (existingOrgByEmail != null) { 53 throw makeOrgEmailExistsError({ shouldErrorSpan: true }); 54 } 55 56 const id = uid(); 57 58 // Create api key before inserting the org, so we don't have to worry about 59 // the possibility of orgs existing without an api key. 60 // TODO: if org fails to save later, delete orphaned api key. 61 const { record } = await this.apiKeyService.createApiKey( 62 id, 63 'Main API Key', 64 'Primary API key for organization', 65 null 66 ); 67 68 // TODO: if org fails to save later, delete orphaned signing key pair 69 await this.signingKeyPairService.createAndStoreSigningKeys(id); 70 71 try { 72 const org = await this.sequelize.Org.create({ 73 id, 74 email, 75 name, 76 websiteUrl: website, 77 apiKeyId: record.id, 78 }); 79 80 await Promise.all([ 81 // This should ideally be done in one transaction, but we can update 82 // this after we move off of sequelize 83 this.moderationConfigService.createDefaultUserType(id), 84 this.orgCreationLogger.logOrgCreated(id, name, email, website), 85 this.userManagementService.upsertOrgDefaultUserInterfaceSettings({ 86 orgId: id, 87 }), 88 this.orgSettingsService.upsertOrgDefaultSettings({ orgId: id }), 89 this.manualReviewToolService.upsertDefaultSettings({ orgId: id }), 90 ]); 91 92 return org; 93 } catch (e) { 94 const activeSpan = this.tracer.getActiveSpan(); 95 if (activeSpan?.isRecording()) { 96 activeSpan.recordException(e as Exception); 97 } 98 throw e; 99 } 100 } 101 102 // Create invite token and optionally send email 103 async inviteUser(input: GQLInviteUserInput, orgId: string) { 104 const { email, role } = input; 105 const org = await this.sequelize.Org.findByPk(orgId, { 106 rejectOnEmpty: true, 107 }); 108 109 const token = await this.userManagementService.createInviteUserToken({ 110 email, 111 role, 112 orgId, 113 }); 114 115 const url = new URL(`${this.config.uiUrl}/signup/${token}`); 116 const msg = { 117 to: email, 118 from: CoopEmailAddress.NoReply, 119 subject: "You've been invited to join your team on Coop!", 120 html: `Hi, and welcome to Coop! Your admin has invited you to join the <strong>${org.name}</strong> Coop team. 121 <br /><br /> 122 Click on <a href='${url.href}'>this link</a> to get started! The link expires in 24 hours, so please make sure to sign up soon. 123 <br /><br /> 124 Best,<br /> 125 Coop Support Team`, 126 }; 127 try { 128 await this.sendEmail(msg); 129 } catch (error: unknown) { 130 // Even if email fails, return the token so it can be copied 131 // eslint-disable-next-line no-console 132 console.warn('Failed to send invite email, but token was created:', error); 133 } 134 return token; 135 } 136 137 async getInviteUserToken(tokenString: string) { 138 const token = await this.userManagementService.getInviteUserToken({ 139 token: tokenString, 140 }); 141 142 // NB: if the db query above returns in a time proportional to the number 143 // of matching characters at the start of the tokenString, then this code 144 // is vulnerable to a timing attack. But we don't care, and can't do much 145 // about it, for right now. 146 // eslint-disable-next-line security/detect-possible-timing-attacks 147 if (token == null) { 148 throw makeInviteUserTokenMissingError({ shouldErrorSpan: true }); 149 } 150 151 if (Date.now() - new Date(token.createdAt).getTime() > 2 * WEEK_MS) { 152 throw makeInviteUserTokenExpiredError({ shouldErrorSpan: true }); 153 } 154 155 return token; 156 } 157 158 async requestDemo(input: GQLRequestDemoInput) { 159 const { email, company, website, interests, ref, isFromGoogleAds } = input; 160 const msg = { 161 to: CoopEmailAddress.Support, 162 from: CoopEmailAddress.NoReply, 163 subject: '[URGENT] Demo Request', 164 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( 165 ', ', 166 )} \n\nRef: ${ref} \n\nIs from Google Ads: ${isFromGoogleAds}`, 167 }; 168 try { 169 await this.sendEmail(msg); 170 } catch (error: unknown) { 171 return false; 172 } 173 return true; 174 } 175 176 async getGraphQLOrgFromId(id: string) { 177 return this.sequelize.Org.findByPk(id, { rejectOnEmpty: true }); 178 } 179 180 async getAllGraphQLOrgs() { 181 return this.sequelize.Org.findAll(); 182 } 183 184 async updateOrgInfo( 185 orgId: string, 186 input: { 187 name?: string | null; 188 email?: string | null; 189 websiteUrl?: string | null; 190 onCallAlertEmail?: string | null; 191 }, 192 ) { 193 const org = await this.sequelize.Org.findByPk(orgId); 194 if (!org) { 195 throw new Error('Organization not found'); 196 } 197 198 if (input.name != null) { 199 org.name = input.name; 200 } 201 if (input.email != null) { 202 org.email = input.email; 203 } 204 if (input.websiteUrl != null && input.websiteUrl !== '') { 205 org.websiteUrl = input.websiteUrl; 206 } 207 if (input.onCallAlertEmail !== undefined) { 208 org.onCallAlertEmail = input.onCallAlertEmail ?? undefined; 209 } 210 211 await org.save(); 212 213 return org; 214 } 215 216 // TODO: ApiKeyService should maybe be its own dataSource, 217 // or just an object on context? 218 async getActivatedApiKeyForOrg(orgId: string) { 219 const apiKeyRecord = await this.apiKeyService.getActiveApiKeyForOrg(orgId); 220 if (!apiKeyRecord) { 221 return false; 222 } 223 return { 224 key: apiKeyRecord.keyHash, 225 metadata: { 226 name: apiKeyRecord.name, 227 description: apiKeyRecord.description ?? '', 228 }, 229 }; 230 } 231 232 /** 233 * Returns the org's webhook public signing key as PEM. If no key exists yet 234 * (e.g. org created before this feature), we create and persist one once. 235 */ 236 async getPublicSigningKeyPem(orgId: string) { 237 let key: CryptoKey; 238 try { 239 key = await this.signingKeyPairService.getSignatureVerificationInfo( 240 orgId, 241 ); 242 } catch (error) { 243 if (isCoopErrorOfType(error, 'SigningKeyPairNotFound')) { 244 key = await this.signingKeyPairService.createAndStoreSigningKeys(orgId); 245 } else { 246 throw error; 247 } 248 } 249 const exported = await crypto.subtle.exportKey('spki', key); 250 const exportedAsBase64 = b64EncodeArrayBuffer(exported); 251 return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 252 } 253 254 /** 255 * Rotates the webhook signing key for the org: generates a new key pair, 256 * overwrites storage, invalidates cache, and returns the new public key as PEM. 257 */ 258 async rotateWebhookSigningKey(orgId: string): Promise<string> { 259 const publicKey = await this.signingKeyPairService.rotateSigningKeys(orgId); 260 const exported = await crypto.subtle.exportKey('spki', publicKey); 261 const exportedAsBase64 = b64EncodeArrayBuffer(exported); 262 return `-----BEGIN PUBLIC KEY-----\n${exportedAsBase64}\n-----END PUBLIC KEY-----`; 263 } 264} 265 266export type OrgErrorType = 267 | 'OrgWithEmailExistsError' 268 | 'OrgWithNameExistsError' 269 | 'InviteUserTokenExpiredError' 270 | 'InviteUserTokenMissingError'; 271 272export const makeOrgEmailExistsError = (data: ErrorInstanceData) => 273 new CoopError({ 274 status: 409, 275 type: [ErrorType.UniqueViolation], 276 title: 'An org with this email already exists', 277 name: 'OrgWithEmailExistsError', 278 ...data, 279 }); 280 281export const makeOrgNameExistsError = (data: ErrorInstanceData) => 282 new CoopError({ 283 status: 409, 284 type: [ErrorType.UniqueViolation], 285 title: 'An org with this name already exists', 286 name: 'OrgWithNameExistsError', 287 ...data, 288 }); 289 290export const makeInviteUserTokenExpiredError = (data: ErrorInstanceData) => 291 new CoopError({ 292 status: 403, 293 type: [ErrorType.Unauthorized], 294 title: 'Invite token expired', 295 name: 'InviteUserTokenExpiredError', 296 ...data, 297 }); 298 299export const makeInviteUserTokenMissingError = (data: ErrorInstanceData) => 300 new CoopError({ 301 status: 401, 302 type: [ErrorType.Unauthorized], 303 title: 'Invite token missing', 304 name: 'InviteUserTokenMissingError', 305 ...data, 306 }); 307 308export default inject( 309 [ 310 'OrgCreationLogger', 311 'ApiKeyService', 312 'sendEmail', 313 'Sequelize', 314 'SigningKeyPairService', 315 'Tracer', 316 'ModerationConfigService', 317 'UserManagementService', 318 'ConfigService', 319 'OrgSettingsService', 320 'ManualReviewToolService', 321 ], 322 OrgAPI, 323); 324export type { OrgAPI };