import { createHash, timingSafeEqual } from "node:crypto"; import { getTranslations } from "~/locale/i18next.server"; import { createSignupTenantWithShkDefaults, deriveSignupTenantId } from "~/domain/tenant-bootstrap/tenantBootstrapService.server"; import { deriveResendCreationRequestKey, deriveVerificationCreationRequestKey, isSignupCreationRequestKey, } from "~/domain/tenant-bootstrap/signupCreationRequestKey.server"; import { CatalogError, foundationErrors, withIdempotency, type IdempotencyClient } from "@hivegrid/foundation"; import { publishOutboxEvent, type OutboxPublisherTransaction } from "@hivegrid/db/outbox"; import UserValidationUtils from "../app/UserValidationUtils"; import { getAppConfiguration } from "../db/appConfiguration.db.server"; import { TenantSlugCollisionError } from "../db/tenants.db.server"; import { queueTransactionalEmail } from "~/modules/emails/services/TransactionalEmailQueue.server"; import { createRegistration, getRegistrationByEmail, getRegistrationByToken, getRegistrationVerificationStateByToken, RegistrationVerificationRotationConflictError, rotateRegistrationVerification, } from "../db/registration.db.server"; import { findLeadForSignedConversionCapability, RegistrationCreateConflictError } from "@hivegrid/db/pretenant-security.server"; import { runWithRlsContext } from "@hivegrid/db/rls-context.server"; import { getTrustedClientIpOrNull } from "~/utils/server/IpUtils"; import { addBlacklistAttempt, findInBlacklist } from "../db/blacklist.db.server"; import { getAbsoluteUrl } from "../url.server"; import { getUserInfo } from "../session.server"; import { AccountCreatedDto } from "~/modules/events/dtos/AccountCreatedDto"; import EventsService from "~/modules/events/services/.server/EventsService"; import { autosubscribeToTrialOrFreePlan } from "./.server/pricingService"; import IpAddressServiceServer from "~/modules/ipAddress/services/IpAddressService.server"; import EmailTemplates from "~/modules/emails/utils/EmailTemplates"; import { getMailTranslator, getMailTranslatorFromRequest } from "~/locale/mail-translator.server"; import { validatePassword as validatePolicyPassword, type PasswordPolicyErrorCode } from "~/services/auth/password-policy.server"; import { verifyLeadToken } from "~/utils/leads/leadToken.server"; import { recordExistingTenantLeadConversion } from "~/utils/leads/convertLead.server"; import { createScopedLogger } from "~/utils/logger.server"; import { resolveCaptchaConfig, verifyCaptchaToken, getSecret, getSitekey, isCaptchaConfigured } from "@hivegrid/captcha"; import { validateDesiredTenantSlug } from "./tenantService"; import { db } from "~/utils/db.server"; import { deriveSignupVerificationToken } from "~/domain/tenant-bootstrap/signupVerificationToken.server"; import { resolveRegistrationSubscriptionPrice } from "~/services/billing/registration-plan-selection.server"; import type { RegistrationBillingPreference } from "~/services/billing/registration-billing-preference.server"; import { adviseRegistrationPlan } from "@hivegrid/entitlements"; export { isSignupCreationRequestKey }; function readOptionalRegistrationSeatQuantity(raw: string): number | undefined { if (raw.length === 0) return undefined; return /^\d{1,5}$/u.test(raw) ? Number(raw) : Number.NaN; } const authServiceLogger = createScopedLogger("auth-service"); const registrationValidationKeys = { invalidEmail: "account.register.errors.invalidEmail", invalidFormData: "auth:errors.invalidFormData", acceptTermsAndPrivacy: "account.register.errors.acceptTermsAndPrivacy", captchaFailed: "account.register.errors.captchaFailed", invalidLeadConversion: "account.register.errors.invalidLeadConversion", passwordRequired: "account.register.errors.passwordRequired", invalidPassword: "auth:errors.invalidPassword", organizationRequired: "account.register.errors.organizationRequired", nameRequired: "account.register.errors.nameRequired", companyMaxLength: "account.register.errors.companyMaxLength", firstNameMaxLength: "account.register.errors.firstNameMaxLength", lastNameMaxLength: "account.register.errors.lastNameMaxLength", blacklistedEmail: "account.register.errors.blacklist.email", blacklistedDomain: "account.register.errors.blacklist.domain", blacklistedIp: "account.register.errors.blacklist.ip", slugReserved: "account.register.errors.slugReserved", slugTooShort: "account.register.errors.slugTooShort", slugRequired: "account.register.errors.slugRequired", usernameRequired: "account.register.errors.usernameRequired", slugTaken: "account.register.errors.slugTaken", usernameTaken: "account.register.errors.usernameTaken", userAlreadyRegistered: "api.errors.userAlreadyRegistered", registrationExpired: "account.register.errors.registrationExpired", priceNotSelected: "account.register.errors.priceNotSelected", priceNotInDatabase: "account.register.errors.priceNotInDatabase", ipBlacklisted: "admin.accounts.ipAddresses.errors.blacklisted", ipUnauthorized: "admin.accounts.ipAddresses.errors.unauthorized", } as const; type RegistrationValidationI18nKey = (typeof registrationValidationKeys)[keyof typeof registrationValidationKeys] | PasswordPolicyErrorCode; export class RegistrationValidationError extends Error { readonly i18nKey: RegistrationValidationI18nKey; readonly i18nArgs?: Record; constructor(i18nKey: RegistrationValidationI18nKey, i18nArgs?: Record) { super(i18nKey); this.name = "RegistrationValidationError"; this.i18nKey = i18nKey; this.i18nArgs = i18nArgs; } } function ipAddressErrorKey(error: string): RegistrationValidationI18nKey | null { if (error === "blacklisted") { return registrationValidationKeys.ipBlacklisted; } if (error === "unauthorized") { return registrationValidationKeys.ipUnauthorized; } return null; } export type RegistrationData = { email?: string; password?: string; company?: string; firstName?: string; lastName?: string; avatarURL?: string; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string; selectedSubscriptionPriceId?: string; selectedOfficeSeatQuantity?: number; selectedFieldSeatQuantity?: number; creationRequestKey?: string; intent?: string; acceptTerms?: boolean; acceptPrivacy?: boolean; /** Captcha token from the client widget (hidden `cap-token` or `mcaptcha__token` field). */ captchaToken?: string; }; export async function getRegistrationFormData(request: Request): Promise { const formData = await request.formData(); const email = formData.get("email")?.toString().toLowerCase().trim(); const password = formData.get("password")?.toString(); const company = formData.get("company")?.toString(); const firstName = formData.get("first-name")?.toString(); const lastName = formData.get("last-name")?.toString(); const slug = formData.get("slug")?.toString(); const leadId = formData.get("leadId")?.toString().trim() || undefined; const leadToken = formData.get("leadToken")?.toString().trim() || undefined; const leadPlanId = formData.get("leadPlanId")?.toString().trim() || undefined; const selectedSubscriptionPriceId = formData.get("selectedSubscriptionPriceId")?.toString().trim() || undefined; const officeSeatQuantityRaw = formData.get("selectedOfficeSeatQuantity")?.toString().trim() ?? ""; const fieldSeatQuantityRaw = formData.get("selectedFieldSeatQuantity")?.toString().trim() ?? ""; const selectedOfficeSeatQuantity = readOptionalRegistrationSeatQuantity(officeSeatQuantityRaw); const selectedFieldSeatQuantity = readOptionalRegistrationSeatQuantity(fieldSeatQuantityRaw); const creationRequestKey = formData.get("creationRequestKey")?.toString().trim() || undefined; const intent = formData.get("intent")?.toString().trim() || undefined; const acceptTerms = formData.get("acceptTerms") === "true"; const acceptPrivacy = formData.get("acceptPrivacy") === "true"; const captchaToken = formData.get("cap-token")?.toString().trim() || formData.get("mcaptcha__token")?.toString().trim() || undefined; const ipError = await IpAddressServiceServer.log(request, { action: "register", description: email ? "registration-attempt" : "{empty email}", metadata: { emailDomain: email?.includes("@") ? email.substring(email.lastIndexOf("@") + 1) : undefined, slug }, }).catch((error) => (error instanceof Error ? error.message : "unknown")); if (ipError) { const i18nKey = ipAddressErrorKey(ipError); if (i18nKey) throw new RegistrationValidationError(i18nKey); throw new Error("Registration request audit failed"); } return { email, password, company, firstName, lastName, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, ...(selectedOfficeSeatQuantity === undefined ? {} : { selectedOfficeSeatQuantity }), ...(selectedFieldSeatQuantity === undefined ? {} : { selectedFieldSeatQuantity }), creationRequestKey, ...(intent ? { intent } : {}), acceptTerms, acceptPrivacy, captchaToken, }; } async function resolveLeadConversionContext( registrationData: RegistrationData, invalidMessage: RegistrationValidationI18nKey, expectedTenantId: string ): Promise<{ leadId: string; planId: string | null } | null> { const hasAnyLeadField = Boolean(registrationData.leadId || registrationData.leadToken || registrationData.leadPlanId); if (!hasAnyLeadField) return null; if (process.env.LEAD_CONVERSION_ENABLED === "false") return null; if (!registrationData.email || !registrationData.leadId || !registrationData.leadToken) { throw new RegistrationValidationError(invalidMessage); } const payload = verifyLeadToken(registrationData.leadToken); if (!payload || payload.leadId !== registrationData.leadId) { throw new RegistrationValidationError(invalidMessage); } const lead = await findLeadForSignedConversionCapability(registrationData.leadId); if ( !lead || (lead.convertedToTenantId !== null && lead.convertedToTenantId !== expectedTenantId) || lead.email.toLowerCase().trim() !== registrationData.email.toLowerCase().trim() ) { throw new RegistrationValidationError(invalidMessage); } return { leadId: registrationData.leadId, planId: payload.planId ?? registrationData.leadPlanId ?? null, }; } export async function validateRegistration({ request, registrationData, addToTrialOrFreePlan, requireSubscriptionSelection = false, requireRegistrationSeatQuantities = requireSubscriptionSelection, checkEmailVerification = true, captchaAlreadyVerified = false, }: { request: Request; registrationData: RegistrationData; addToTrialOrFreePlan: boolean; requireSubscriptionSelection?: boolean; requireRegistrationSeatQuantities?: boolean; checkEmailVerification?: boolean; captchaAlreadyVerified?: boolean; }) { const { t } = await getTranslations(request); const userInfo = await getUserInfo(request); const appConfiguration = await getAppConfiguration({ request }); const { email, password, company, firstName, lastName, avatarURL, slug, creationRequestKey } = registrationData; if (!email || !UserValidationUtils.validateEmail(email)) { throw new RegistrationValidationError(registrationValidationKeys.invalidEmail); } if (!creationRequestKey || !isSignupCreationRequestKey(creationRequestKey)) { throw new RegistrationValidationError(registrationValidationKeys.invalidFormData); } const expectedSignupTenantId = deriveSignupTenantId(email, creationRequestKey); const selectedSubscriptionPrice = registrationData.selectedSubscriptionPriceId ? await resolveRegistrationSubscriptionPrice(registrationData.selectedSubscriptionPriceId) : null; if (requireSubscriptionSelection && !registrationData.selectedSubscriptionPriceId) { throw new RegistrationValidationError(registrationValidationKeys.priceNotSelected); } if (requireSubscriptionSelection && !selectedSubscriptionPrice) { throw new RegistrationValidationError(registrationValidationKeys.priceNotInDatabase); } const hasOfficeSeatQuantity = registrationData.selectedOfficeSeatQuantity !== undefined; const hasFieldSeatQuantity = registrationData.selectedFieldSeatQuantity !== undefined; if (hasOfficeSeatQuantity !== hasFieldSeatQuantity || (requireRegistrationSeatQuantities && !hasOfficeSeatQuantity)) { throw new RegistrationValidationError(registrationValidationKeys.invalidFormData); } if (hasOfficeSeatQuantity && hasFieldSeatQuantity) { try { adviseRegistrationPlan({ officeSeatQuantity: registrationData.selectedOfficeSeatQuantity!, fieldSeatQuantity: registrationData.selectedFieldSeatQuantity!, }); } catch { throw new RegistrationValidationError(registrationValidationKeys.invalidFormData); } } // Anti-spam: verify the captcha token for human signups (email/password is // the only signup path after EU-Sterilisation removed social OAuth). No-op when // captcha is unconfigured. When configured, deployed runtimes fail closed if // the provider is unreachable; only local development/test may opt out. const captcha = resolveCaptchaConfig(process.env); if (isCaptchaConfigured(captcha) && !captchaAlreadyVerified) { const captchaOk = await verifyCaptchaToken({ token: registrationData.captchaToken, sitekey: getSitekey(captcha, "login"), secret: getSecret(captcha, "login"), instanceUrl: captcha.instanceUrl, provider: captcha.provider, failOpen: captcha.failOpen, logger: authServiceLogger, }); if (!captchaOk) { throw new RegistrationValidationError(registrationValidationKeys.captchaFailed); } } const leadConversionContext = await resolveLeadConversionContext(registrationData, registrationValidationKeys.invalidLeadConversion, expectedSignupTenantId); const createsAccountNow = !checkEmailVerification || !appConfiguration.auth.requireEmailVerification; if (createsAccountNow && (typeof password !== "string" || password.length === 0)) { throw new RegistrationValidationError(registrationValidationKeys.passwordRequired); } if (createsAccountNow && password) { const policyResult = await validatePolicyPassword(password, { email }); if (!policyResult.valid) { const firstError = policyResult.errors[0]; throw firstError ? new RegistrationValidationError(firstError.code, firstError.args) : new RegistrationValidationError(registrationValidationKeys.invalidPassword); } } if (appConfiguration.auth.requireOrganization && !company?.trim()) { throw new RegistrationValidationError(registrationValidationKeys.organizationRequired); } if (appConfiguration.auth.requireName && (!firstName?.trim() || !lastName?.trim())) { throw new RegistrationValidationError(registrationValidationKeys.nameRequired); } if (company && company.length > 100) { throw new RegistrationValidationError(registrationValidationKeys.companyMaxLength); } else if (firstName && firstName.length > 50) { throw new RegistrationValidationError(registrationValidationKeys.firstNameMaxLength); } else if (lastName && lastName.length > 50) { throw new RegistrationValidationError(registrationValidationKeys.lastNameMaxLength); } const ipAddress = getTrustedClientIpOrNull(request) ?? ""; const blacklistedEmail = await findInBlacklist("email", email); if (blacklistedEmail) { await addBlacklistAttempt(blacklistedEmail); throw new RegistrationValidationError(registrationValidationKeys.blacklistedEmail); } const blacklistedDomain = await findInBlacklist("domain", email.substring(email.lastIndexOf("@") + 1)); if (blacklistedDomain) { await addBlacklistAttempt(blacklistedDomain); throw new RegistrationValidationError(registrationValidationKeys.blacklistedDomain); } const blacklistedIp = await findInBlacklist("ip", ipAddress); if (blacklistedIp) { await addBlacklistAttempt(blacklistedIp); throw new RegistrationValidationError(registrationValidationKeys.blacklistedIp); } // The deterministic signup service performs the authoritative replay check. // An exact retry after commit must reach it instead of failing on email // uniqueness here; a different request key or payload fails closed there. // Vom Nutzer GEWAEHLTER Slug: reservierte/zu-kurze Werte mit Klartext ablehnen, statt sie // spaeter in createUserAndTenant/getAvailableTenantSlug still umzuschreiben (S1/R-P2a). Greift // unabhaengig von auth.slug.require, sobald ein Slug uebermittelt wurde. if (typeof slug === "string" && slug.trim().length > 0) { const slugCheck = validateDesiredTenantSlug(slug); if (!slugCheck.ok && slugCheck.reason !== "empty") { throw new RegistrationValidationError( slugCheck.reason === "reserved" ? registrationValidationKeys.slugReserved : registrationValidationKeys.slugTooShort ); } } if (appConfiguration.auth.slug?.require) { if (!slug) { throw new RegistrationValidationError( appConfiguration.auth.slug.type === "tenant" ? registrationValidationKeys.slugRequired : registrationValidationKeys.usernameRequired ); } } if (checkEmailVerification && appConfiguration.auth.requireEmailVerification) { return { email, ipAddress, verificationRequired: true, selectedSubscriptionPriceId: selectedSubscriptionPrice?.priceId, selectedOfficeSeatQuantity: registrationData.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: registrationData.selectedFieldSeatQuantity, }; } const locale = userInfo.lng; const billingPreference = selectedSubscriptionPrice && registrationData.selectedOfficeSeatQuantity !== undefined && registrationData.selectedFieldSeatQuantity !== undefined ? { selectedSubscriptionPriceId: selectedSubscriptionPrice.priceId, selectedOfficeSeatQuantity: registrationData.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: registrationData.selectedFieldSeatQuantity, } : undefined; const registered = await createUserAndTenant({ request, email, password, company, firstName, lastName, avatarURL, locale, slug, creationRequestKey, ...(billingPreference ? { billingPreference } : {}), }); await runWithRlsContext({ tenantId: registered.tenant.id, userId: registered.user.id }, async () => { if (addToTrialOrFreePlan) { await runRequiredRegistrationPostCommitStep("subscription.autosubscribe", registered.tenant.id, registered.user.id, () => autosubscribeToTrialOrFreePlan({ request, t, tenantId: registered.tenant.id, userId: registered.user.id, idempotencyKey: creationRequestKey, throwOnFailure: true, }) ); } if (leadConversionContext) { try { await recordExistingTenantLeadConversion({ leadId: leadConversionContext.leadId, tenantId: registered.tenant.id, userId: registered.user.id, expectedEmail: email, planId: leadConversionContext.planId, conversionPath: "self-service", }); } catch (error) { authServiceLogger.warn( { op: "lead.selfServiceConversion.record_failed", leadId: leadConversionContext.leadId, tenantId: registered.tenant.id, err: { name: error instanceof Error ? error.name : "Error" }, }, "Registration succeeded but lead conversion attribution could not be recorded." ); } } }); return { email, ipAddress, verificationRequired: false, registered, selectedSubscriptionPriceId: selectedSubscriptionPrice?.priceId, selectedOfficeSeatQuantity: registrationData.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: registrationData.selectedFieldSeatQuantity, }; } interface CreateRegistrationFormDto { request: Request; email: string; ipAddress: string; company?: string; firstName?: string; lastName?: string; resendOnly?: boolean; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string | null; selectedSubscriptionPriceId?: string | null; selectedOfficeSeatQuantity?: number | null; selectedFieldSeatQuantity?: number | null; creationRequestKey: string; resendRecovery?: { registrationId: string; catchUpOnly: boolean; }; } const REGISTRATION_RESEND_COOLDOWN_MS = 60 * 1000; export async function createRegistrationForm({ request, email, company, firstName, lastName, ipAddress, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, creationRequestKey, resendRecovery, resendOnly = false, }: CreateRegistrationFormDto) { const registration = await getRegistrationByEmail(email); const effectiveCreationRequestKey = creationRequestKey; const token = deriveSignupVerificationToken(email, effectiveCreationRequestKey); if (resendOnly && resendRecovery) { if (!registration || registration.createdTenantId || registration.id !== resendRecovery.registrationId) { throw registrationReplayConflict(); } return await recoverPersistedRegistrationResend({ request, email, registrationId: registration.id, creationRequestKey: effectiveCreationRequestKey, catchUpOnly: resendRecovery.catchUpOnly, }); } if (registration) { if (registration.createdTenantId) { throw new RegistrationValidationError(registrationValidationKeys.userAlreadyRegistered); } const replay = await getRegistrationByToken(token); if (replay) { assertExactPendingRegistrationReplay(replay, registration.id, { email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, token, }); if (resendOnly) { if (isRegistrationResendCooldownActive(replay.createdAt)) { // Keep the public success shape and current capability stable. An // exact retry after response loss still follows the rotated-token // recovery branch below and reuses its idempotent mail job. return { creationRequestKey: effectiveCreationRequestKey }; } return await rotateAndEnqueueRegistrationVerification({ request, registrationId: registration.id, expectedToken: token, email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, }); } await enqueueRegistrationVerificationEmail({ request, registrationId: registration.id, token, email, firstName }); return { creationRequestKey: effectiveCreationRequestKey }; } const submittedTokenState = await getRegistrationVerificationStateByToken(token); if (submittedTokenState.status === "expired" && submittedTokenState.registration.id === registration.id) { assertExactPendingRegistrationReplay(submittedTokenState.registration, registration.id, { email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, token, }); if (!resendOnly) { throw new RegistrationValidationError(registrationValidationKeys.registrationExpired); } return await rotateAndEnqueueRegistrationVerification({ request, registrationId: registration.id, expectedToken: token, email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, }); } else if (resendOnly) { return await rotateAndEnqueueRegistrationVerification({ request, registrationId: registration.id, expectedToken: token, email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, }); } } else if (resendOnly) { throw registrationReplayConflict(); } let createdRegistration: Awaited>; try { createdRegistration = await createRegistration({ email, firstName: firstName ?? "", lastName: lastName ?? "", company: company ?? "", token, selectedSubscriptionPriceId: selectedSubscriptionPriceId ?? null, selectedOfficeSeatQuantity: selectedOfficeSeatQuantity ?? null, selectedFieldSeatQuantity: selectedFieldSeatQuantity ?? null, leadConversionLeadId: leadId ?? null, leadConversionToken: leadToken ?? null, leadConversionPlanId: leadPlanId ?? null, ipAddress, slug: slug ?? null, }); } catch (error) { if (!(error instanceof RegistrationCreateConflictError) && !isUniqueConstraintViolation(error)) throw error; const replay = await getRegistrationByToken(token); if (!replay) throw registrationReplayConflict(); assertExactPendingRegistrationReplay(replay, replay.id, { email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, token, }); createdRegistration = replay; } await enqueueRegistrationVerificationEmail({ request, registrationId: createdRegistration.id, token, email, firstName }); return { creationRequestKey: effectiveCreationRequestKey }; } export type ResolvedRegistrationResendRecovery = { email: string; creationRequestKey: string; registrationId: string; catchUpOnly: boolean; selectedSubscriptionPriceId?: string | null; selectedOfficeSeatQuantity?: number | null; selectedFieldSeatQuantity?: number | null; }; /** * Resolves a signed browser recovery binding to the still-pending database * row. At most one deterministic resend rotation is followed so a response * lost after commit can be recovered without exposing an email-only token * lookup capability. */ export async function resolvePendingRegistrationResendRecovery(input: { email: string; creationRequestKey: string; }): Promise { const email = input.email.trim().toLowerCase(); const summary = await getRegistrationByEmail(email); if (!summary || summary.createdTenantId) return null; const token = deriveSignupVerificationToken(email, input.creationRequestKey); const current = await getRegistrationVerificationStateByToken(token); if ((current.status === "pending" || current.status === "expired") && isBoundPendingRegistration(current.registration, summary.id, email)) { return { email, creationRequestKey: input.creationRequestKey, registrationId: summary.id, catchUpOnly: false, selectedSubscriptionPriceId: current.registration.selectedSubscriptionPriceId, selectedOfficeSeatQuantity: current.registration.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: current.registration.selectedFieldSeatQuantity, }; } if (current.status !== "invalid") return null; const nextCreationRequestKey = deriveResendCreationRequestKey(summary.id, token); const nextToken = deriveSignupVerificationToken(email, nextCreationRequestKey); const next = await getRegistrationVerificationStateByToken(nextToken); if ((next.status === "pending" || next.status === "expired") && isBoundPendingRegistration(next.registration, summary.id, email)) { return { email, creationRequestKey: nextCreationRequestKey, registrationId: summary.id, catchUpOnly: true, selectedSubscriptionPriceId: next.registration.selectedSubscriptionPriceId, selectedOfficeSeatQuantity: next.registration.selectedOfficeSeatQuantity, selectedFieldSeatQuantity: next.registration.selectedFieldSeatQuantity, }; } return null; } async function recoverPersistedRegistrationResend({ request, email, registrationId, creationRequestKey, catchUpOnly, }: { request: Request; email: string; registrationId: string; creationRequestKey: string; catchUpOnly: boolean; }) { const token = deriveSignupVerificationToken(email, creationRequestKey); const state = await getRegistrationVerificationStateByToken(token); if ((state.status !== "pending" && state.status !== "expired") || !isBoundPendingRegistration(state.registration, registrationId, email)) { throw registrationReplayConflict(); } if (catchUpOnly && state.status === "pending") { await enqueueRegistrationVerificationEmail({ request, registrationId, token, email: state.registration.email, firstName: state.registration.firstName, }); return { creationRequestKey }; } // A catch-up capability can outlive the token it recovered. Never enqueue // that expired token again: rotate it through the same compare-and-set path // below so PostgreSQL atomically renews both the token and its 24-hour TTL. if (state.status === "pending" && isRegistrationResendCooldownActive(state.registration.createdAt)) { return { creationRequestKey }; } const nextCreationRequestKey = deriveResendCreationRequestKey(registrationId, token); const nextToken = deriveSignupVerificationToken(email, nextCreationRequestKey); let rotationError: unknown; try { await rotateRegistrationVerification(registrationId, token, nextToken); } catch (error) { rotationError = error; } const nextState = await getRegistrationVerificationStateByToken(nextToken); if ((nextState.status !== "pending" && nextState.status !== "expired") || !isBoundPendingRegistration(nextState.registration, registrationId, email)) { if (rotationError instanceof RegistrationVerificationRotationConflictError) throw registrationReplayConflict(); if (rotationError) throw rotationError; throw registrationReplayConflict(); } await enqueueRegistrationVerificationEmail({ request, registrationId, token: nextToken, email: nextState.registration.email, firstName: nextState.registration.firstName, }); return { creationRequestKey: nextCreationRequestKey }; } function isBoundPendingRegistration(registration: PendingRegistrationReplay, registrationId: string, normalizedEmail: string): boolean { return registration.id === registrationId && registration.createdTenantId === null && registration.email.trim().toLowerCase() === normalizedEmail; } function isRegistrationResendCooldownActive(createdAt: Date | string, now = new Date()): boolean { const createdAtMs = new Date(createdAt).getTime(); const elapsedMs = now.getTime() - createdAtMs; return !Number.isFinite(createdAtMs) || elapsedMs < REGISTRATION_RESEND_COOLDOWN_MS; } async function rotateAndEnqueueRegistrationVerification({ request, registrationId, expectedToken, email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, }: { request: Request; registrationId: string; expectedToken: string; email: string; firstName?: string; lastName?: string; company?: string; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string | null; selectedSubscriptionPriceId?: string | null; selectedOfficeSeatQuantity?: number | null; selectedFieldSeatQuantity?: number | null; ipAddress: string; }) { const creationRequestKey = deriveResendCreationRequestKey(registrationId, expectedToken); const token = deriveSignupVerificationToken(email, creationRequestKey); let rotationError: unknown; try { await rotateRegistrationVerification(registrationId, expectedToken, token); } catch (error) { rotationError = error; } const replay = await getRegistrationByToken(token); if (!replay) { if (rotationError instanceof RegistrationVerificationRotationConflictError) throw registrationReplayConflict(); if (rotationError) throw rotationError; throw registrationReplayConflict(); } assertExactPendingRegistrationReplay(replay, registrationId, { email, firstName, lastName, company, slug, leadId, leadToken, leadPlanId, selectedSubscriptionPriceId, selectedOfficeSeatQuantity, selectedFieldSeatQuantity, ipAddress, token, }); await enqueueRegistrationVerificationEmail({ request, registrationId, token, email, firstName }); return { creationRequestKey }; } type PendingRegistrationReplay = NonNullable>>; function assertExactPendingRegistrationReplay( registration: PendingRegistrationReplay | null, expectedRegistrationId: string, expected: { email: string; firstName?: string; lastName?: string; company?: string; slug?: string; leadId?: string; leadToken?: string; leadPlanId?: string | null; selectedSubscriptionPriceId?: string | null; selectedOfficeSeatQuantity?: number | null; selectedFieldSeatQuantity?: number | null; ipAddress: string; token: string; } ): asserts registration is PendingRegistrationReplay { const tokenMatches = registration ? constantTimeTextEqual(registration.token, expected.token) : false; const matches = registration?.id === expectedRegistrationId && registration.createdTenantId === null && tokenMatches && registration.email.trim().toLowerCase() === expected.email.trim().toLowerCase() && registration.firstName === (expected.firstName ?? "") && registration.lastName === (expected.lastName ?? "") && registration.company === (expected.company ?? "") && registration.slug === (expected.slug ?? null) && registration.leadConversionLeadId === (expected.leadId ?? null) && registration.leadConversionToken === (expected.leadToken ?? null) && registration.leadConversionPlanId === (expected.leadPlanId ?? null) && registration.selectedSubscriptionPriceId === (expected.selectedSubscriptionPriceId ?? null) && registration.selectedOfficeSeatQuantity === (expected.selectedOfficeSeatQuantity ?? null) && registration.selectedFieldSeatQuantity === (expected.selectedFieldSeatQuantity ?? null) && registration.ipAddress === expected.ipAddress; if (!matches) throw registrationReplayConflict(); } function constantTimeTextEqual(actual: string, expected: string): boolean { const actualDigest = createHash("sha256").update(actual).digest(); const expectedDigest = createHash("sha256").update(expected).digest(); return timingSafeEqual(actualDigest, expectedDigest); } function registrationReplayConflict(): CatalogError { return new CatalogError(foundationErrors.idempotencyConflict, { scope: "signup-registration" }); } function isUniqueConstraintViolation(error: unknown): boolean { if (!error || typeof error !== "object") return false; const candidate = error as { code?: unknown; meta?: { code?: unknown } }; return candidate.code === "P2002" || (candidate.code === "P2010" && candidate.meta?.code === "23505"); } async function enqueueRegistrationVerificationEmail({ request, registrationId, token, email, firstName, }: { request: Request; registrationId: string; token: string; email: string; firstName?: string; }): Promise { const boundCreationRequestKey = deriveVerificationCreationRequestKey(registrationId); const tokenDigest = createHash("sha256").update(token).digest("hex").slice(0, 32); const emailData = { action_url: getAbsoluteUrl(request, verificationPath(token, boundCreationRequestKey)), name: firstName ?? "", }; const mailTranslator = await getMailTranslatorFromRequest(request); await queueTransactionalEmail({ to: email, alias: "email-verification", ...EmailTemplates.VERIFICATION_EMAIL.parse({ appConfiguration: await getAppConfiguration({ request }), data: emailData, t: mailTranslator.t, locale: mailTranslator.locale, }), data: emailData, priorityOverride: "critical", idempotencyKey: `registration-verification:${registrationId}:${tokenDigest}`, }); } interface CreateUserAndTenantDto { request: Request; email: string; password?: string; company?: string; firstName?: string; lastName?: string; avatarURL?: string; locale?: string; slug?: string; creationRequestKey: string; billingPreference?: RegistrationBillingPreference; } export async function createUserAndTenant({ request, email, password, company, firstName, lastName, avatarURL, locale, slug, creationRequestKey, billingPreference, }: CreateUserAndTenantDto) { const { t } = await getTranslations(request); const appConfiguration = await getAppConfiguration({ request }); let signupResult: Awaited>; try { signupResult = await createSignupTenantWithShkDefaults({ email, password: password ?? "", firstName: firstName ?? "", lastName: lastName ?? "", avatarURL, locale, company: company ?? email.split("@")[0], slug, creationRequestKey, ...(billingPreference ? { billingPreference } : {}), t, }); } catch (error) { if (error instanceof TenantSlugCollisionError) { throw new RegistrationValidationError( appConfiguration.auth?.slug?.type === "username" ? registrationValidationKeys.usernameTaken : registrationValidationKeys.slugTaken ); } throw error; } const { tenant, user } = signupResult; const emailData = { action_url: getAbsoluteUrl(request, "/login"), name: firstName ?? "", }; const welcomeMailTranslator = await getMailTranslator(user.locale ?? locale); const welcomeEmail = EmailTemplates.WELCOME_EMAIL.parse({ appConfiguration, data: emailData, t: welcomeMailTranslator.t, locale: welcomeMailTranslator.locale, }); const accountCreatedData = { tenant: { id: tenant.id, name: tenant.name, slug: tenant.slug }, user: { id: user.id, email: user.email }, } satisfies AccountCreatedDto; await persistSignupPostCommitEffects({ tenantId: tenant.id, userId: user.id, creationRequestKey, email: user.email, welcomeEmail, accountCreatedData, }); return { user, tenant }; } async function persistSignupPostCommitEffects({ tenantId, userId, creationRequestKey, email, welcomeEmail, accountCreatedData, }: { tenantId: string; userId: string; creationRequestKey: string; email: string; welcomeEmail: { subject: string; body: string; text: string }; accountCreatedData: AccountCreatedDto; }): Promise { const welcomeDispatchKey = `mail:signup-welcome:${tenantId}:${creationRequestKey}`; await runWithRlsContext({ tenantId, userId }, async () => { await db.$transaction(async (tx) => { await withIdempotency( { tenantId, scope: "signup-welcome-mail", key: creationRequestKey, fingerprint: { contract: "signup-welcome-mail/v1", tenantId, userId, email, }, }, tx as unknown as IdempotencyClient, async () => { await publishOutboxEvent(tx as unknown as OutboxPublisherTransaction, { tenantId, type: "mail.transactional.send", dispatchKey: welcomeDispatchKey, payload: { tenantId, to: [email], subject: welcomeEmail.subject, text: welcomeEmail.text, html: welcomeEmail.body, metadata: { source: "signup", alias: "welcome", idempotencyKey: welcomeDispatchKey, }, }, }); return { status: 201, body: { state: "completed" }, }; } ); }); // Keep event recording, workflow triggering and the admin notification on // the canonical EventsService path. Its deterministic event ID and source // key make retries safe even when the first response is lost. await EventsService.createIdempotentTenantEvent({ event: "account.created", tenantId, userId, data: accountCreatedData, idempotencyKey: `signup-account-created:${tenantId}:${creationRequestKey}`, }); }); } function verificationPath(token: string, creationRequestKey: string): string { if (!isSignupCreationRequestKey(creationRequestKey)) { throw new Error("Invalid signup creation request key"); } return `/verify/${token}?creationRequestKey=${encodeURIComponent(creationRequestKey)}`; } async function runRequiredRegistrationPostCommitStep(operation: string, tenantId: string, userId: string, callback: () => Promise): Promise { try { await callback(); } catch (error) { authServiceLogger.warn( { op: operation, tenantId, userId, err: { name: error instanceof Error ? error.name : "Error" }, }, "Registration committed, but a post-commit step failed." ); throw error; } }