import crypto from 'node:crypto' export interface DesktopLinkRecord { userId: string email: string plan: string role: string canDownload: boolean accessStatus: string createdAt: number } interface DesktopLinkRateLimitBucket { count: number resetAt: number } interface DesktopLinkStoreOptions { ttlMs: number rateLimitMax: number rateLimitWindowMs: number now?: () => number randomBytes?: (size: number) => Buffer } interface DesktopLinkIssueInput { rateLimitKey: string userId: string email: string plan: string role: string canDownload: boolean accessStatus: string } type DesktopLinkIssueResult = | { ok: true; token: string; expiresAt: string } | { ok: false; error: 'rate_limited' } function normalizeOriginCandidate(candidate: string): string | null { const trimmed = candidate.trim() if (!trimmed) { return null } try { return new URL(trimmed).origin.toLowerCase() } catch { return null } } export function buildAllowedOriginMatcher(websiteDomain: string) { const allowedOrigin = normalizeOriginCandidate(websiteDomain) if (!allowedOrigin) { throw new Error(`WEBSITE_DOMAIN '${websiteDomain}' must be an absolute origin.`) } return (candidate: string): boolean => normalizeOriginCandidate(candidate) === allowedOrigin } export function createDesktopLinkStore({ ttlMs, rateLimitMax, rateLimitWindowMs, now = () => Date.now(), randomBytes = crypto.randomBytes, }: DesktopLinkStoreOptions) { const desktopLinkTokens = new Map() const rateLimitBuckets = new Map() function cleanupExpiredTokens() { const cutoff = now() - ttlMs for (const [token, record] of desktopLinkTokens) { if (record.createdAt < cutoff) { desktopLinkTokens.delete(token) } } for (const [key, bucket] of rateLimitBuckets) { if (bucket.resetAt <= now()) { rateLimitBuckets.delete(key) } } } function isRateLimited(rateLimitKey: string) { const currentTime = now() const bucket = rateLimitBuckets.get(rateLimitKey) if (!bucket || currentTime >= bucket.resetAt) { rateLimitBuckets.set(rateLimitKey, { count: 1, resetAt: currentTime + rateLimitWindowMs, }) return false } bucket.count += 1 return bucket.count > rateLimitMax } function issueToken({ rateLimitKey, userId, email, plan, role, canDownload, accessStatus, }: DesktopLinkIssueInput): DesktopLinkIssueResult { cleanupExpiredTokens() if (isRateLimited(rateLimitKey)) { return { ok: false, error: 'rate_limited' } } const createdAt = now() const token = randomBytes(32).toString('hex') desktopLinkTokens.set(token, { userId, email, plan, role, canDownload, accessStatus, createdAt, }) return { ok: true, token, expiresAt: new Date(createdAt + ttlMs).toISOString(), } } function verifyAndConsumeToken(token: string): DesktopLinkRecord | null { cleanupExpiredTokens() const normalizedToken = token.trim() if (!/^[a-f0-9]{64}$/i.test(normalizedToken)) { return null } const record = desktopLinkTokens.get(normalizedToken) if (!record) { return null } desktopLinkTokens.delete(normalizedToken) return record } return { cleanupExpiredTokens, issueToken, verifyAndConsumeToken, getTokenCount: () => desktopLinkTokens.size, } }