-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
156 lines (142 loc) · 6.12 KB
/
Copy pathindex.ts
File metadata and controls
156 lines (142 loc) · 6.12 KB
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
/**
* Web-boundary utilities every agent app's routes hand-roll: JSON body parsing
* + narrowing, request-context extraction (real client IP behind Cloudflare),
* a KV-backed sliding-window rate limiter, and security response headers. Pure
* mechanism — no DB, no domain. The KV is a structural interface so this needs
* no `@cloudflare/workers-types` dependency.
*/
export type JsonObject = Record<string, unknown>
/** Parse + object-narrow a Request body. `[body, null]` on success, `[null,
* errorResponse]` on a non-object body (callers `if (err) return err`). */
export async function parseJsonObjectBody(request: Request): Promise<[JsonObject, null] | [null, Response]> {
let raw: unknown
try {
raw = await request.json()
} catch {
return [null, Response.json({ error: 'Invalid JSON body' }, { status: 400 })]
}
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
return [null, Response.json({ error: 'Body must be a JSON object' }, { status: 400 })]
}
return [raw as JsonObject, null]
}
/** Narrow one required string field, 400 if missing/empty. */
export function requireString(body: JsonObject, field: string): string | Response {
const v = body[field]
if (typeof v !== 'string' || v.length === 0) {
return Response.json({ error: `Missing or non-string field: ${field}` }, { status: 400 })
}
return v
}
export interface RequestContext {
ipAddress: string
userAgent: string
timestamp: string
requestId: string
}
/** Extract request context for audit trails. Uses `CF-Connecting-IP` for the
* real client IP behind Cloudflare. */
export function extractRequestContext(request: Request): RequestContext {
const ipAddress =
request.headers.get('CF-Connecting-IP') ??
request.headers.get('X-Forwarded-For')?.split(',')[0]?.trim() ??
'0.0.0.0'
return {
ipAddress,
userAgent: request.headers.get('User-Agent') ?? '',
timestamp: new Date().toISOString(),
requestId: crypto.randomUUID(),
}
}
/** Minimal KV contract (Cloudflare `KVNamespace` satisfies it structurally). */
export interface KvLike {
get(key: string): Promise<string | null>
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>
}
export interface RateLimitResult {
allowed: boolean
remaining: number
resetAt: number
}
/** KV-backed sliding-window rate limit. Stores recent timestamps per key,
* prunes the window, allows until `limit` is hit. */
export async function checkRateLimit(kv: KvLike, key: string, limit: number, windowSeconds: number): Promise<RateLimitResult> {
const now = Math.floor(Date.now() / 1000)
const windowStart = now - windowSeconds
const kvKey = `rl:${key}`
const raw = await kv.get(kvKey)
const timestamps: number[] = raw ? JSON.parse(raw) : []
const valid = timestamps.filter((t) => t > windowStart)
if (valid.length >= limit) return { allowed: false, remaining: 0, resetAt: (valid[0] ?? now) + windowSeconds }
valid.push(now)
await kv.put(kvKey, JSON.stringify(valid), { expirationTtl: windowSeconds * 2 })
return { allowed: true, remaining: limit - valid.length, resetAt: now + windowSeconds }
}
export interface CookieOptions {
name: string
/** Default '/'. */
path?: string
/** Default true. */
httpOnly?: boolean
/** Adds the `Secure` attribute. Default false. */
secure?: boolean
/** Default 'Lax'. */
sameSite?: 'Lax' | 'Strict' | 'None'
maxAgeSeconds?: number
}
/** Serialize a Set-Cookie header value: `name=encodeURIComponent(value)` plus
* attributes in Path / HttpOnly / SameSite / Max-Age / Secure order.
* Throws on `SameSite=None` without `secure` — browsers silently drop that
* combination, which would otherwise fail invisibly. */
export function serializeCookie(value: string, opts: CookieOptions): string {
if (opts.sameSite === 'None' && !opts.secure) {
throw new Error('SameSite=None cookies require secure: true (browsers reject them otherwise)')
}
const parts = [`${opts.name}=${encodeURIComponent(value)}`, `Path=${opts.path ?? '/'}`]
if (opts.httpOnly !== false) parts.push('HttpOnly')
parts.push(`SameSite=${opts.sameSite ?? 'Lax'}`)
if (opts.maxAgeSeconds !== undefined) parts.push(`Max-Age=${opts.maxAgeSeconds}`)
if (opts.secure) parts.push('Secure')
return parts.join('; ')
}
/** Set-Cookie header value that deletes the cookie (empty value, Max-Age=0). */
export function clearCookieHeader(opts: Omit<CookieOptions, 'maxAgeSeconds'>): string {
return serializeCookie('', { ...opts, maxAgeSeconds: 0 })
}
/** Read + decode one cookie from a Cookie request header; null when absent. */
export function readCookieValue(cookieHeader: string | null, name: string): string | null {
if (!cookieHeader) return null
for (const part of cookieHeader.split(/;\s*/)) {
const [cookieName, ...rest] = part.split('=')
if (cookieName === name) {
try {
return decodeURIComponent(rest.join('='))
} catch {
return null
}
}
}
return null
}
export interface SecurityHeaderOptions {
/** Product disclaimer (e.g. "AI-powered tool. Not legal advice."). Omitted if absent. */
disclaimer?: string
/** Data-retention label (e.g. "7-years"). Omitted if absent. */
retention?: string
/** Extra headers to set. */
extra?: Record<string, string>
}
/** Set standard security headers on a response (HSTS, nosniff, frame-options,
* referrer-policy, XSS) + optional product disclaimer/retention. The security
* set is generic; the disclaimer/retention are the product's. */
export function addSecurityHeaders(response: Response, opts: SecurityHeaderOptions = {}): Response {
response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('X-Frame-Options', 'SAMEORIGIN')
response.headers.set('Referrer-Policy', 'same-origin')
response.headers.set('X-XSS-Protection', '1; mode=block')
if (opts.disclaimer) response.headers.set('X-AI-Disclaimer', opts.disclaimer)
if (opts.retention) response.headers.set('X-Data-Retention', opts.retention)
for (const [k, v] of Object.entries(opts.extra ?? {})) response.headers.set(k, v)
return response
}