Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
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 };