"use client"; import { z } from "zod"; import { StorageKeys } from "@/data/storage/storage_keys"; import { ChatLockTypeSchema } from "@/data/schemas/chat"; import { getCharacterById } from "@/data/constants/character"; import { normalizeLegacyCharacterId } from "@/data/constants/character_id_aliases"; import { Result } from "@/utils/result"; import { SessionAsyncUtil } from "@/utils/session-storage"; const MAX_AGE_MS = 30 * 60 * 1000; export type PendingChatUnlockKind = "private" | "voice" | "image"; export type PendingChatUnlockStage = "auth" | "payment"; export type PendingChatPromotionType = "voice" | "image" | "private"; export const PendingChatPromotionSchema = z.object({ characterId: z.string().min(1), promotionType: z.enum(["voice", "image", "private"]), lockType: ChatLockTypeSchema, clientLockId: z.string().min(1), createdAt: z.number(), }); const PendingChatUnlockSchema = z .object({ reason: z.literal("single_message_unlock"), characterId: z.string().min(1), displayMessageId: z.string().min(1), messageId: z.string().min(1).optional(), kind: z.enum(["private", "voice", "image"]), lockType: ChatLockTypeSchema.optional(), clientLockId: z.string().min(1).optional(), promotion: PendingChatPromotionSchema.optional(), returnUrl: z .string() .min(1) .refine( (value) => value.startsWith("/") && !value.startsWith("//"), "returnUrl must be an internal route", ), stage: z.enum(["auth", "payment"]), createdAt: z.number(), }) .refine((value) => value.messageId || value.lockType, { message: "messageId or lockType is required", }); const PendingChatImageReturnSchema = z.object({ reason: z.literal("image_paywall"), characterId: z.string().min(1), messageId: z.string().min(1), returnUrl: z .string() .min(1) .refine( (value) => value.startsWith("/") && !value.startsWith("//"), "returnUrl must be an internal route", ), createdAt: z.number(), }); export type PendingChatUnlock = z.output; export type PendingChatPromotion = z.output< typeof PendingChatPromotionSchema >; export type PendingChatImageReturn = z.output< typeof PendingChatImageReturnSchema >; export function createPendingChatPromotion( promotionType: PendingChatPromotionType, characterId: string, ): PendingChatPromotion { return PendingChatPromotionSchema.parse({ characterId, promotionType, lockType: toPromotionLockType(promotionType), clientLockId: `promotion_${createUuidV4()}`, createdAt: Date.now(), }); } /** * NavigationStorage owns short-lived cross-route sessions. * * UI / router code should not touch the raw session storage utility directly. * Keeping these payloads here makes auth, payment, and chat return flows * testable without spreading storage keys across screens. */ export class NavigationStorage { private constructor() {} static async savePendingChatUnlock(input: { characterId: string; displayMessageId?: string; messageId?: string; kind: PendingChatUnlockKind; lockType?: PendingChatUnlock["lockType"]; clientLockId?: string; promotion?: PendingChatPromotion; returnUrl: string; stage: PendingChatUnlockStage; }): Promise { const displayMessageId = input.displayMessageId ?? input.messageId ?? (input.clientLockId ? `promotion:${input.clientLockId}` : null); if (!displayMessageId) { throw new Error("displayMessageId is required"); } if ( input.promotion && input.promotion.characterId !== input.characterId ) { throw new Error("Pending promotion belongs to a different character."); } const payload: PendingChatUnlock = { reason: "single_message_unlock", characterId: input.characterId, displayMessageId, ...(input.messageId ? { messageId: input.messageId } : {}), kind: input.kind, ...(input.lockType ? { lockType: input.lockType } : {}), ...(input.clientLockId ? { clientLockId: input.clientLockId } : {}), ...(input.promotion ? { promotion: input.promotion } : {}), returnUrl: input.returnUrl, stage: input.stage, createdAt: Date.now(), }; await SessionAsyncUtil.setJson( StorageKeys.pendingChatUnlock, payload, PendingChatUnlockSchema, ); } static async consumePendingChatUnlock( characterId: string, ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatUnlock, PendingChatUnlockSchema, ); if (Result.isErr(result)) { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return null; } const value = NavigationStorage.parsePendingChatUnlock(result.data); if (!value) { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return null; } if (value.characterId !== normalizeLegacyCharacterId(characterId)) { return null; } await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return value; } static async peekPendingChatUnlock( characterId: string, ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatUnlock, PendingChatUnlockSchema, ); if (Result.isErr(result)) { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); return null; } const value = NavigationStorage.parsePendingChatUnlock(result.data); if (!value) { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); } return value?.characterId === normalizeLegacyCharacterId(characterId) ? value : null; } static async hasPendingChatUnlock(characterId: string): Promise { return ( (await NavigationStorage.peekPendingChatUnlock(characterId)) !== null ); } static async clearPendingChatUnlock(): Promise { await SessionAsyncUtil.remove(StorageKeys.pendingChatUnlock); } static async savePendingChatPromotion( promotionType: PendingChatPromotionType, characterId: string, ): Promise { const promotion = createPendingChatPromotion( promotionType, characterId, ); await SessionAsyncUtil.setJson( StorageKeys.pendingChatPromotion, promotion, PendingChatPromotionSchema, ); return promotion; } static async consumePendingChatPromotion( characterId: string, ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatPromotion, PendingChatPromotionSchema, ); if (Result.isErr(result)) { await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); return null; } const value = NavigationStorage.parsePendingChatPromotion(result.data); if (!value) { await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); return null; } if (value.characterId !== normalizeLegacyCharacterId(characterId)) { return null; } await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); return value; } static async clearPendingChatPromotion(): Promise { await SessionAsyncUtil.remove(StorageKeys.pendingChatPromotion); } static async savePendingChatImageReturn(input: { characterId: string; messageId: string; returnUrl: string; }): Promise { const payload: PendingChatImageReturn = { reason: "image_paywall", characterId: input.characterId, messageId: input.messageId, returnUrl: input.returnUrl, createdAt: Date.now(), }; await SessionAsyncUtil.setJson( StorageKeys.pendingChatImageReturn, payload, PendingChatImageReturnSchema, ); } static async consumePendingChatImageReturn( characterId: string, ): Promise { const result = await SessionAsyncUtil.getJson( StorageKeys.pendingChatImageReturn, PendingChatImageReturnSchema, ); if (Result.isErr(result)) { await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); return null; } const value = NavigationStorage.parsePendingChatImageReturn(result.data); if (!value) { await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); return null; } if (value.characterId !== normalizeLegacyCharacterId(characterId)) { return null; } await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); return value; } static async clearPendingChatImageReturn(): Promise { await SessionAsyncUtil.remove(StorageKeys.pendingChatImageReturn); } private static parsePendingChatUnlock( value: PendingChatUnlock | null, ): PendingChatUnlock | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; const characterId = normalizeLegacyCharacterId(value.characterId); if (!getCharacterById(characterId)) return null; const promotion = value.promotion ? NavigationStorage.parsePendingChatPromotion(value.promotion) : undefined; if (value.promotion && !promotion) return null; return PendingChatUnlockSchema.parse({ ...value, characterId, ...(promotion ? { promotion } : {}), }); } private static parsePendingChatImageReturn( value: PendingChatImageReturn | null, ): PendingChatImageReturn | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; const characterId = normalizeLegacyCharacterId(value.characterId); return getCharacterById(characterId) ? PendingChatImageReturnSchema.parse({ ...value, characterId }) : null; } private static parsePendingChatPromotion( value: PendingChatPromotion | null, ): PendingChatPromotion | null { if (!value || Date.now() - value.createdAt > MAX_AGE_MS) return null; const characterId = normalizeLegacyCharacterId(value.characterId); return getCharacterById(characterId) ? PendingChatPromotionSchema.parse({ ...value, characterId }) : null; } } function toPromotionLockType( promotionType: PendingChatPromotionType, ): PendingChatPromotion["lockType"] { if (promotionType === "voice") return "voice_message"; if (promotionType === "image") return "image_paywall"; return "private_message"; } function createUuidV4(): string { const cryptoApi = globalThis.crypto; if (typeof cryptoApi?.randomUUID === "function") { return cryptoApi.randomUUID(); } const bytes = new Uint8Array(16); if (typeof cryptoApi?.getRandomValues === "function") { cryptoApi.getRandomValues(bytes); } else { for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Math.floor(Math.random() * 256); } } bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); return [ hex.slice(0, 4).join(""), hex.slice(4, 6).join(""), hex.slice(6, 8).join(""), hex.slice(8, 10).join(""), hex.slice(10, 16).join(""), ].join("-"); }