Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/services/authService.ts
T

1109 lines
41 KiB
TypeScript

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<string, string | number>;
constructor(i18nKey: RegistrationValidationI18nKey, i18nArgs?: Record<string, string | number>) {
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<RegistrationData> {
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<ReturnType<typeof createRegistration>>;
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<ResolvedRegistrationResendRecovery | null> {
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<Awaited<ReturnType<typeof getRegistrationByToken>>>;
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<void> {
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<ReturnType<typeof createSignupTenantWithShkDefaults>>;
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<void> {
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<unknown>): Promise<void> {
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;
}
}