hypertwist/website/server/src/security.ts
2026-06-22 02:18:59 +00:00

158 lines
3.5 KiB
TypeScript

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<string, DesktopLinkRecord>()
const rateLimitBuckets = new Map<string, DesktopLinkRateLimitBucket>()
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,
}
}