114 lines
4.7 KiB
TypeScript
114 lines
4.7 KiB
TypeScript
import {
|
|
createRegistrationCapability,
|
|
findRegistrationBillingPreferenceCapability,
|
|
findRegistrationByTokenCapability,
|
|
findRegistrationSummaryByEmail,
|
|
rotateRegistrationVerificationCapability,
|
|
updateRegistrationCapability,
|
|
} from "@hivegrid/db/pretenant-security.server";
|
|
import { deriveVerificationCreationRequestKey } from "~/domain/tenant-bootstrap/signupCreationRequestKey.server";
|
|
|
|
export const REGISTRATION_VERIFICATION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
const REGISTRATION_VERIFICATION_TOKEN_PATTERN = /^[a-f0-9]{40}$/iu;
|
|
type RegistrationRecord = NonNullable<Awaited<ReturnType<typeof findRegistrationByTokenCapability>>>;
|
|
|
|
export type RegistrationVerificationState =
|
|
| { status: "invalid" }
|
|
| { status: "expired"; registration: RegistrationRecord }
|
|
| { status: "used"; registration: RegistrationRecord }
|
|
| { status: "pending"; registration: RegistrationRecord };
|
|
|
|
export class RegistrationVerificationRotationConflictError extends Error {
|
|
constructor() {
|
|
super("Registration verification rotation capability was rejected");
|
|
this.name = "RegistrationVerificationRotationConflictError";
|
|
}
|
|
}
|
|
|
|
export async function getRegistrationByEmail(email: string) {
|
|
return await findRegistrationSummaryByEmail(email);
|
|
}
|
|
|
|
export async function getRegistrationBillingPreference(tenantId: string, userId: string) {
|
|
return await findRegistrationBillingPreferenceCapability(tenantId, userId);
|
|
}
|
|
|
|
export async function getRegistrationByToken(token: string, now = new Date()) {
|
|
const state = await getRegistrationVerificationStateByToken(token, now);
|
|
return state.status === "pending" || state.status === "used" ? state.registration : null;
|
|
}
|
|
|
|
export async function getRegistrationVerificationStateByToken(token: string, now = new Date()): Promise<RegistrationVerificationState> {
|
|
if (!REGISTRATION_VERIFICATION_TOKEN_PATTERN.test(token)) return { status: "invalid" };
|
|
const registration = await findRegistrationByTokenCapability(token);
|
|
if (!registration) return { status: "invalid" };
|
|
if (registration.createdTenantId) return { status: "used", registration };
|
|
if (!isRegistrationVerificationFresh(registration.createdAt, now)) return { status: "expired", registration };
|
|
return { status: "pending", registration };
|
|
}
|
|
|
|
/** Minimal projection for the public verification page. */
|
|
export async function getRegistrationVerificationViewByToken(token: string, now = new Date()) {
|
|
const state = await getRegistrationVerificationStateByToken(token, now);
|
|
if (state.status !== "pending") return { status: state.status } as const;
|
|
return {
|
|
status: "pending" as const,
|
|
email: state.registration.email,
|
|
creationRequestKey: deriveVerificationCreationRequestKey(state.registration.id),
|
|
};
|
|
}
|
|
|
|
export function isRegistrationVerificationFresh(createdAt: Date | string, now = new Date()): boolean {
|
|
const createdAtMs = new Date(createdAt).getTime();
|
|
const nowMs = now.getTime();
|
|
return Number.isFinite(createdAtMs) && nowMs >= createdAtMs && nowMs - createdAtMs < REGISTRATION_VERIFICATION_TTL_MS;
|
|
}
|
|
|
|
export async function createRegistration(data: {
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
token: string;
|
|
company: string | null;
|
|
selectedSubscriptionPriceId: string | null;
|
|
selectedOfficeSeatQuantity: number | null;
|
|
selectedFieldSeatQuantity: number | null;
|
|
leadConversionLeadId?: string | null;
|
|
leadConversionToken?: string | null;
|
|
leadConversionPlanId?: string | null;
|
|
ipAddress: string | null;
|
|
slug: string | null;
|
|
}) {
|
|
return await createRegistrationCapability({
|
|
email: data.email,
|
|
firstName: data.firstName,
|
|
lastName: data.lastName,
|
|
token: data.token,
|
|
company: data.company,
|
|
selectedSubscriptionPriceId: data.selectedSubscriptionPriceId,
|
|
selectedOfficeSeatQuantity: data.selectedOfficeSeatQuantity,
|
|
selectedFieldSeatQuantity: data.selectedFieldSeatQuantity,
|
|
leadConversionLeadId: data.leadConversionLeadId ?? null,
|
|
leadConversionToken: data.leadConversionToken ?? null,
|
|
leadConversionPlanId: data.leadConversionPlanId ?? null,
|
|
ipAddress: data.ipAddress,
|
|
slug: data.slug,
|
|
});
|
|
}
|
|
|
|
export async function rotateRegistrationVerification(id: string, expectedToken: string, token: string) {
|
|
const updated = await rotateRegistrationVerificationCapability({ id, expectedToken, token });
|
|
if (!updated) {
|
|
throw new RegistrationVerificationRotationConflictError();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function updateRegistration(id: string, capability: { expectedToken: string }, data: { createdTenantId: string }) {
|
|
const updated = await updateRegistrationCapability({ id, expectedToken: capability.expectedToken, patch: data });
|
|
if (!updated) {
|
|
throw new Error("Registration update capability was rejected");
|
|
}
|
|
return updated;
|
|
}
|