158 lines
3.5 KiB
TypeScript
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,
|
|
}
|
|
}
|