All files / lib preview-token.ts

100% Statements 31/31
100% Branches 9/9
100% Functions 4/4
100% Lines 30/30

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211                    2x                                                                                                                                                                                                 26x   26x               26x 26x     26x 26x               26x             26x           26x   26x                     13x 13x 13x 2x       11x 11x                 11x   11x 228x   11x             8x 3x       5x 5x     5x 1x     3x   4x               11x    
/**
 * Preview Token Utilities
 *
 * Generates and validates self-signed preview tokens using HMAC.
 * Tokens are stateless - no database storage required.
 *
 * Token format: base64(JSON({subdomain, proId, exp})).signature
 */
 
// Reserved subdomains that pros cannot use
export const RESERVED_SUBDOMAINS = new Set([
	// Infrastructure
	"www",
	"api",
	"app",
	"admin",
	"portal",
	"dashboard",
	"console",
	"panel",
	// Email/Communication
	"mail",
	"email",
	"smtp",
	"imap",
	"pop",
	"webmail",
	// Security/Auth
	"auth",
	"login",
	"signin",
	"signup",
	"register",
	"account",
	"oauth",
	"sso",
	// Common services
	"ftp",
	"sftp",
	"cdn",
	"static",
	"assets",
	"images",
	"media",
	"files",
	"download",
	"upload",
	// Development/Testing
	"dev",
	"test",
	"staging",
	"preview",
	"demo",
	"sandbox",
	"beta",
	"alpha",
	// Support
	"help",
	"support",
	"docs",
	"documentation",
	"status",
	"blog",
	"news",
	// Platform specific
	"decorrocket",
	"decor-rocket",
	"interioring",
	"marketplace",
	"pro",
	"pros",
	"vendor",
	"vendors",
	"internal",
	"system",
	"root",
	"null",
	"undefined",
	// DNS/Network
	"ns",
	"ns1",
	"ns2",
	"mx",
	"autodiscover",
	"autoconfig",
	"_dmarc",
	"_domainkey",
]);
 
interface PreviewTokenPayload {
	sub: string; // subdomain
	vid: string; // proId
	uid: string; // userId who generated the token
	exp: number; // expiration timestamp (seconds)
}
 
/**
 * Create a signed preview token
 * Uses HMAC-SHA256 for signing
 */
export async function createPreviewToken(
	subdomain: string,
	proId: string,
	userId: string,
	secret: string,
	expiresInMinutes = 30,
): Promise<{ token: string; expiresAt: Date }> {
	const expiresAt = new Date(Date.now() + expiresInMinutes * 60 * 1000);
 
	const payload: PreviewTokenPayload = {
		sub: subdomain,
		vid: proId,
		uid: userId,
		exp: Math.floor(expiresAt.getTime() / 1000),
	};
 
	// Encode payload as base64
	const payloadStr = JSON.stringify(payload);
	const payloadB64 = btoa(payloadStr);
 
	// Sign with HMAC-SHA256
	const encoder = new TextEncoder();
	const key = await crypto.subtle.importKey(
		"raw",
		encoder.encode(secret),
		{ name: "HMAC", hash: "SHA-256" },
		false,
		["sign"],
	);
 
	const signature = await crypto.subtle.sign(
		"HMAC",
		key,
		encoder.encode(payloadB64),
	);
 
	// Convert signature to base64url (URL-safe)
	const sigB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
		.replace(/\+/g, "-")
		.replace(/\//g, "_")
		.replace(/=+$/, "");
 
	// Token format: payload.signature
	const token = `${payloadB64}.${sigB64}`;
 
	return { token, expiresAt };
}
 
/**
 * Validate a preview token
 * Returns the payload if valid, null if invalid
 */
export async function validatePreviewToken(
	token: string,
	secret: string,
): Promise<PreviewTokenPayload | null> {
	try {
		const [payloadB64, sigB64] = token.split(".");
		if (!payloadB64 || !sigB64) {
			return null;
		}
 
		// Verify signature
		const encoder = new TextEncoder();
		const key = await crypto.subtle.importKey(
			"raw",
			encoder.encode(secret),
			{ name: "HMAC", hash: "SHA-256" },
			false,
			["verify"],
		);
 
		// Convert base64url back to regular base64
		const sigB64Normal = sigB64.replace(/-/g, "+").replace(/_/g, "/");
		const sigPadded =
			sigB64Normal + "=".repeat((4 - (sigB64Normal.length % 4)) % 4);
		const sigBytes = Uint8Array.from(atob(sigPadded), (c) => c.charCodeAt(0));
 
		const isValid = await crypto.subtle.verify(
			"HMAC",
			key,
			sigBytes,
			encoder.encode(payloadB64),
		);
 
		if (!isValid) {
			return null;
		}
 
		// Decode and validate payload
		const payloadStr = atob(payloadB64);
		const payload = JSON.parse(payloadStr) as PreviewTokenPayload;
 
		// Check expiration
		if (payload.exp < Math.floor(Date.now() / 1000)) {
			return null;
		}
 
		return payload;
	} catch {
		return null;
	}
}
 
/**
 * Check if a subdomain is reserved
 */
export function isReservedSubdomain(subdomain: string): boolean {
	return RESERVED_SUBDOMAINS.has(subdomain.toLowerCase());
}