import crypto from 'node:crypto'; import { URL } from 'node:url'; import { inject, type Dependencies } from '../../iocContainer/index.js'; import { CoopEmailAddress } from '../../services/sendEmailService/index.js'; import { b64EncodeArrayBuffer } from '../../utils/encoding.js'; import { CoopError, ErrorType, isCoopErrorOfType, makeBadRequestError, type ErrorInstanceData, } from '../../utils/errors.js'; import { WEEK_MS } from '../../utils/time.js'; import { type GQLInviteUserInput } from '../generated.js'; import { kyselyOrgFindById, kyselyOrgUpdate, type GraphQLOrgParent, } from './orgKyselyPersistence.js'; import { validateOrgUpdatePatch, type OrgValidationFailure, } from './orgValidation.js'; import { kyselyUserListByOrg, type GraphQLUserParent, } from './userKyselyPersistence.js'; class OrgAPI { constructor( private readonly orgCreationLogger: Dependencies['OrgCreationLogger'], private readonly apiKeyService: Dependencies['ApiKeyService'], private readonly sendEmail: Dependencies['sendEmail'], 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'], private readonly kysely: Dependencies['KyselyPg'], ) {} // Create invite token and optionally send email async inviteUser(input: GQLInviteUserInput, orgId: string) { const { email, role } = input; const org = await kyselyOrgFindById(this.kysely, orgId); if (org == null) { throw new Error(`Organization not found: ${orgId}`); } 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 getGraphQLOrgFromId(id: string): Promise { const org = await kyselyOrgFindById(this.kysely, id); if (org == null) { throw new Error(`Organization not found: ${id}`); } return org; } async updateOrgInfo( orgId: string, input: { name?: string | null; email?: string | null; websiteUrl?: string | null; onCallAlertEmail?: string | null; }, ): Promise { const validation = validateOrgUpdatePatch(input); if (!validation.ok) { throw orgValidationFailureToBadRequestError(validation.failure); } 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'); } return updated; } async getContentTypesForOrg(orgId: string) { return this.moderationConfigService.getItemTypes({ orgId }); } async getOrgUsersForGraphQL(orgId: string): Promise { return kyselyUserListByOrg(this.kysely, orgId); } // 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 = 'InviteUserTokenExpiredError' | 'InviteUserTokenMissingError'; function orgValidationFailureToBadRequestError(failure: OrgValidationFailure) { return makeBadRequestError(failure.message, { pointer: `/input/${failure.field}`, shouldErrorSpan: false, }); } 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', 'SigningKeyPairService', 'Tracer', 'ModerationConfigService', 'UserManagementService', 'ConfigService', 'OrgSettingsService', 'ManualReviewToolService', 'KyselyPg', ], OrgAPI, ); export type { OrgAPI };