1109 lines
41 KiB
TypeScript
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;
|
|
}
|
|
}
|