kennel 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +203 -0
- package/bin/kennel.mjs +3 -0
- package/dist/automations/notifications.d.ts +40 -0
- package/dist/automations/notifications.js +220 -0
- package/dist/cli/dispatch.d.ts +1 -0
- package/dist/cli/dispatch.js +51 -0
- package/dist/cli/usage.d.ts +1 -0
- package/dist/cli/usage.js +14 -0
- package/dist/deploy/cloudflare.d.ts +6 -0
- package/dist/deploy/cloudflare.js +91 -0
- package/dist/deploy/environment.d.ts +2 -0
- package/dist/deploy/environment.js +47 -0
- package/dist/deploy/smoke-tests.d.ts +3 -0
- package/dist/deploy/smoke-tests.js +71 -0
- package/dist/deploy/trigger-dev.d.ts +2 -0
- package/dist/deploy/trigger-dev.js +255 -0
- package/dist/main.d.ts +1 -0
- package/dist/main.js +8 -0
- package/dist/mcp/agent-tools.d.ts +8 -0
- package/dist/mcp/agent-tools.js +247 -0
- package/dist/mcp/config.d.ts +25 -0
- package/dist/mcp/config.js +171 -0
- package/dist/mcp/data.d.ts +14 -0
- package/dist/mcp/data.js +120 -0
- package/dist/mcp/function-source.d.ts +7 -0
- package/dist/mcp/function-source.js +40 -0
- package/dist/mcp/generate.d.ts +1 -0
- package/dist/mcp/generate.js +47 -0
- package/dist/mcp/install-page.d.ts +1 -0
- package/dist/mcp/install-page.js +360 -0
- package/dist/notifications/email-worker.d.ts +8 -0
- package/dist/notifications/email-worker.js +137 -0
- package/dist/package/mcp-data.d.ts +1 -0
- package/dist/package/mcp-data.js +1 -0
- package/dist/package/mcp-email-worker.d.ts +1 -0
- package/dist/package/mcp-email-worker.js +1 -0
- package/dist/package/mcp-http.d.ts +1 -0
- package/dist/package/mcp-http.js +1 -0
- package/dist/package/mcp-stdio.d.ts +1 -0
- package/dist/package/mcp-stdio.js +1 -0
- package/dist/package/mcp.d.ts +1 -0
- package/dist/package/mcp.js +1 -0
- package/dist/publish/cloudflare-init.d.ts +12 -0
- package/dist/publish/cloudflare-init.js +98 -0
- package/dist/publish/config.d.ts +28 -0
- package/dist/publish/config.js +190 -0
- package/dist/publish/options.d.ts +13 -0
- package/dist/publish/options.js +78 -0
- package/dist/schema/doctor.d.ts +1 -0
- package/dist/schema/doctor.js +69 -0
- package/dist/schema/documents.d.ts +18 -0
- package/dist/schema/documents.js +127 -0
- package/dist/schema/pointers.d.ts +5 -0
- package/dist/schema/pointers.js +48 -0
- package/dist/schema/types.d.ts +36 -0
- package/dist/schema/types.js +1 -0
- package/dist/schema/validation.d.ts +8 -0
- package/dist/schema/validation.js +212 -0
- package/dist/shared/json.d.ts +4 -0
- package/dist/shared/json.js +56 -0
- package/dist/shared/object.d.ts +2 -0
- package/dist/shared/object.js +3 -0
- package/dist/shared/package-manager.d.ts +11 -0
- package/dist/shared/package-manager.js +39 -0
- package/dist/shared/path.d.ts +4 -0
- package/dist/shared/path.js +40 -0
- package/dist/shared/process.d.ts +7 -0
- package/dist/shared/process.js +12 -0
- package/dist/shared/project-config.d.ts +2 -0
- package/dist/shared/project-config.js +26 -0
- package/dist/site/dev.d.ts +10 -0
- package/dist/site/dev.js +128 -0
- package/dist/site/vitepress.d.ts +21 -0
- package/dist/site/vitepress.js +66 -0
- package/package.json +74 -0
- package/templates/cloudflare/email-code-worker.js +344 -0
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kennel",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Schema-backed folder ledger validator for structured workspaces.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"kennel": "bin/kennel.mjs"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./mcp": {
|
|
12
|
+
"types": "./dist/package/mcp.d.ts",
|
|
13
|
+
"import": "./dist/package/mcp.js"
|
|
14
|
+
},
|
|
15
|
+
"./mcp/data": {
|
|
16
|
+
"types": "./dist/package/mcp-data.d.ts",
|
|
17
|
+
"import": "./dist/package/mcp-data.js"
|
|
18
|
+
},
|
|
19
|
+
"./mcp/http": {
|
|
20
|
+
"types": "./dist/package/mcp-http.d.ts",
|
|
21
|
+
"import": "./dist/package/mcp-http.js"
|
|
22
|
+
},
|
|
23
|
+
"./mcp/notifications/email-worker": {
|
|
24
|
+
"types": "./dist/package/mcp-email-worker.d.ts",
|
|
25
|
+
"import": "./dist/package/mcp-email-worker.js"
|
|
26
|
+
},
|
|
27
|
+
"./mcp/stdio": {
|
|
28
|
+
"types": "./dist/package/mcp-stdio.d.ts",
|
|
29
|
+
"import": "./dist/package/mcp-stdio.js"
|
|
30
|
+
},
|
|
31
|
+
"./automations/notifications": {
|
|
32
|
+
"types": "./dist/automations/notifications.d.ts",
|
|
33
|
+
"import": "./dist/automations/notifications.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"registry": "https://registry.npmjs.org",
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"files": [
|
|
41
|
+
"bin",
|
|
42
|
+
"dist",
|
|
43
|
+
"templates"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
48
|
+
"test": "npm run build && node --test tests/*.test.mjs",
|
|
49
|
+
"check": "npm run build && node --check bin/kennel.mjs && node --check dist/main.js",
|
|
50
|
+
"prepack": "npm run build",
|
|
51
|
+
"pack:check": "npm pack --dry-run",
|
|
52
|
+
"publish:public": "npm publish --access public --registry=https://registry.npmjs.org --@raggle-ai:registry=https://registry.npmjs.org",
|
|
53
|
+
"publish:public:dry-run": "npm publish --dry-run --access public --registry=https://registry.npmjs.org --@raggle-ai:registry=https://registry.npmjs.org"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"@raggle-ai/kennel-mcp": "^0.1.7",
|
|
57
|
+
"ajv": "^8.17.1",
|
|
58
|
+
"ajv-formats": "^3.0.1",
|
|
59
|
+
"effect": "^3.11.9",
|
|
60
|
+
"yaml": "^2.9.0"
|
|
61
|
+
},
|
|
62
|
+
"devDependencies": {
|
|
63
|
+
"@types/node": "^22.13.4",
|
|
64
|
+
"typescript": "^5.9.3"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=20"
|
|
68
|
+
},
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/raggle-ai/kennel.git",
|
|
72
|
+
"directory": "cli"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
const AUTH_PREFIX = '/__kennel_auth'
|
|
2
|
+
const SESSION_COOKIE = '__kennel_auth_session'
|
|
3
|
+
const CHALLENGE_COOKIE = '__kennel_auth_challenge'
|
|
4
|
+
const SESSION_MAX_AGE = 60 * 60 * 24 * 7
|
|
5
|
+
const CHALLENGE_MAX_AGE = 60 * 10
|
|
6
|
+
|
|
7
|
+
const encoder = new TextEncoder()
|
|
8
|
+
|
|
9
|
+
export default {
|
|
10
|
+
async fetch(request, env) {
|
|
11
|
+
const url = new URL(request.url)
|
|
12
|
+
|
|
13
|
+
if (isAdminMcpRequest(url, env)) return proxyAdminMcpRequest(request, env)
|
|
14
|
+
|
|
15
|
+
if (url.pathname === `${AUTH_PREFIX}/request` && request.method === 'POST') return requestCode(request, env)
|
|
16
|
+
if (url.pathname === `${AUTH_PREFIX}/verify` && request.method === 'POST') return verifyCode(request, env)
|
|
17
|
+
if (url.pathname === `${AUTH_PREFIX}/logout`) return logout(url)
|
|
18
|
+
|
|
19
|
+
if (isPublicAsset(url.pathname, env)) return assetResponse(request, env)
|
|
20
|
+
if (request.headers.has('Cf-Access-Jwt-Assertion')) return assetResponse(request, env)
|
|
21
|
+
if (await hasSession(request, env)) return assetResponse(request, env)
|
|
22
|
+
|
|
23
|
+
return loginPage(url)
|
|
24
|
+
},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isAdminMcpRequest(url, env) {
|
|
28
|
+
if (!env.KENNEL_ADMIN_MCP_UPSTREAM) return false
|
|
29
|
+
if (!url.pathname.startsWith('/mcp')) return false
|
|
30
|
+
const hostnames = csv(env.KENNEL_ADMIN_MCP_HOSTNAMES)
|
|
31
|
+
return hostnames.length === 0 || hostnames.includes(url.hostname.toLowerCase())
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function proxyAdminMcpRequest(request, env) {
|
|
35
|
+
if (!env.KENNEL_MCP_ACCESS_HEADER_TOKEN) return json({ error: 'Missing KENNEL_MCP_ACCESS_HEADER_TOKEN.' }, 500)
|
|
36
|
+
|
|
37
|
+
const incomingUrl = new URL(request.url)
|
|
38
|
+
const upstream = new URL(env.KENNEL_ADMIN_MCP_UPSTREAM)
|
|
39
|
+
upstream.search = incomingUrl.search
|
|
40
|
+
|
|
41
|
+
const headers = new Headers(request.headers)
|
|
42
|
+
headers.delete('host')
|
|
43
|
+
headers.set('x-kennel-access-token', env.KENNEL_MCP_ACCESS_HEADER_TOKEN)
|
|
44
|
+
headers.set('x-kennel-mcp-role', env.KENNEL_ADMIN_MCP_ROLE || 'admin')
|
|
45
|
+
const email = request.headers.get('cf-access-authenticated-user-email')
|
|
46
|
+
if (email) headers.set('cf-access-authenticated-user-email', email)
|
|
47
|
+
|
|
48
|
+
return fetch(new Request(upstream, {
|
|
49
|
+
body: request.body,
|
|
50
|
+
headers,
|
|
51
|
+
method: request.method,
|
|
52
|
+
redirect: 'manual',
|
|
53
|
+
}))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function assetResponse(request, env) {
|
|
57
|
+
const response = await env.ASSETS.fetch(request)
|
|
58
|
+
if (response.status !== 404) return response
|
|
59
|
+
|
|
60
|
+
const url = new URL(request.url)
|
|
61
|
+
const base = siteBase(env)
|
|
62
|
+
if (!base || !url.pathname.startsWith(base)) return response
|
|
63
|
+
|
|
64
|
+
const stripped = new URL(request.url)
|
|
65
|
+
stripped.pathname = `/${url.pathname.slice(base.length)}`.replace(/\/+/g, '/')
|
|
66
|
+
return env.ASSETS.fetch(new Request(stripped, request))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isPublicAsset(pathname, env) {
|
|
70
|
+
const normalized = stripSiteBase(pathname, env)
|
|
71
|
+
return normalized.startsWith('/assets/') || normalized === '/favicon.ico' || normalized === '/robots.txt' || normalized === '/vp-icons.css'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function stripSiteBase(pathname, env) {
|
|
75
|
+
const base = siteBase(env)
|
|
76
|
+
if (!base || !pathname.startsWith(base)) return pathname
|
|
77
|
+
return `/${pathname.slice(base.length)}`.replace(/\/+/g, '/')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function siteBase(env) {
|
|
81
|
+
const base = String(env.KENNEL_SITE_BASE ?? '').trim()
|
|
82
|
+
if (!base || base === '/') return ''
|
|
83
|
+
return `/${base.replace(/^\/+|\/+$/g, '')}/`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function requestCode(request, env) {
|
|
87
|
+
const url = new URL(request.url)
|
|
88
|
+
const form = await request.formData()
|
|
89
|
+
const email = String(form.get('email') ?? '').trim().toLowerCase()
|
|
90
|
+
const redirect = cleanRedirect(form.get('redirect'))
|
|
91
|
+
|
|
92
|
+
if (!email || !isAllowedEmail(email, env)) return loginPage(url, 'Use an approved email address.', email, redirect)
|
|
93
|
+
|
|
94
|
+
const code = String(crypto.getRandomValues(new Uint32Array(1))[0] % 1000000).padStart(6, '0')
|
|
95
|
+
const expires = Math.floor(Date.now() / 1000) + CHALLENGE_MAX_AGE
|
|
96
|
+
const challenge = await signPayload({
|
|
97
|
+
email,
|
|
98
|
+
codeHash: await codeDigest(email, code, expires, env),
|
|
99
|
+
expires,
|
|
100
|
+
}, env)
|
|
101
|
+
|
|
102
|
+
await sendCode(email, code, env)
|
|
103
|
+
|
|
104
|
+
return htmlResponse(verifyPage(email, redirect), {
|
|
105
|
+
'Set-Cookie': cookie(CHALLENGE_COOKIE, challenge, CHALLENGE_MAX_AGE, url),
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function verifyCode(request, env) {
|
|
110
|
+
const url = new URL(request.url)
|
|
111
|
+
const form = await request.formData()
|
|
112
|
+
const code = String(form.get('code') ?? '').replace(/\D/g, '')
|
|
113
|
+
const redirect = cleanRedirect(form.get('redirect'))
|
|
114
|
+
const challenge = await readSignedCookie(request, CHALLENGE_COOKIE, env)
|
|
115
|
+
|
|
116
|
+
if (!challenge?.email || !challenge.codeHash) return loginPage(url, 'That code expired. Request a new one.', '', redirect)
|
|
117
|
+
if (challenge.expires < Math.floor(Date.now() / 1000)) return loginPage(url, 'That code expired. Request a new one.', challenge.email, redirect)
|
|
118
|
+
if ((await codeDigest(challenge.email, code, challenge.expires, env)) !== challenge.codeHash) return htmlResponse(verifyPage(challenge.email, redirect, 'That code did not match.'))
|
|
119
|
+
|
|
120
|
+
const session = await signPayload({
|
|
121
|
+
email: challenge.email,
|
|
122
|
+
expires: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE,
|
|
123
|
+
}, env)
|
|
124
|
+
|
|
125
|
+
return redirectResponse(redirect, {
|
|
126
|
+
'Set-Cookie': [cookie(SESSION_COOKIE, session, SESSION_MAX_AGE, url), cookie(CHALLENGE_COOKIE, '', 0, url)],
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function logout(url) {
|
|
131
|
+
return redirectResponse('/', {
|
|
132
|
+
'Set-Cookie': [cookie(SESSION_COOKIE, '', 0, url), cookie(CHALLENGE_COOKIE, '', 0, url)],
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function hasSession(request, env) {
|
|
137
|
+
const session = await readSignedCookie(request, SESSION_COOKIE, env)
|
|
138
|
+
if (!session?.email || !session.expires) return false
|
|
139
|
+
if (session.expires < Math.floor(Date.now() / 1000)) return false
|
|
140
|
+
return isAllowedEmail(session.email, env)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function isAllowedEmail(email, env) {
|
|
144
|
+
const allowedEmails = csv(env.KENNEL_AUTH_ALLOWED_EMAILS)
|
|
145
|
+
const allowedDomains = csv(env.KENNEL_AUTH_ALLOWED_DOMAINS)
|
|
146
|
+
const domain = email.split('@').pop()
|
|
147
|
+
|
|
148
|
+
return allowedEmails.includes(email) || allowedDomains.includes(domain)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function sendCode(email, code, env) {
|
|
152
|
+
if (!env.EMAIL?.send) throw new Error('Missing Cloudflare Email binding named EMAIL.')
|
|
153
|
+
if (!env.KENNEL_AUTH_FROM_EMAIL) throw new Error('Missing KENNEL_AUTH_FROM_EMAIL.')
|
|
154
|
+
|
|
155
|
+
await env.EMAIL.send({
|
|
156
|
+
to: email,
|
|
157
|
+
from: env.KENNEL_AUTH_FROM_EMAIL,
|
|
158
|
+
subject: env.KENNEL_AUTH_EMAIL_SUBJECT ?? 'Your sign-in code',
|
|
159
|
+
text: `Your sign-in code is ${code}.\n\nIt expires in 10 minutes.`,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function signPayload(payload, env) {
|
|
164
|
+
const body = base64Url(JSON.stringify(payload))
|
|
165
|
+
const signature = await hmac(body, env)
|
|
166
|
+
return `${body}.${signature}`
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function readSignedCookie(request, name, env) {
|
|
170
|
+
const value = parseCookies(request.headers.get('Cookie')).get(name)
|
|
171
|
+
if (!value) return undefined
|
|
172
|
+
|
|
173
|
+
const [body, signature] = value.split('.')
|
|
174
|
+
if (!body || !signature) return undefined
|
|
175
|
+
if ((await hmac(body, env)) !== signature) return undefined
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(fromBase64Url(body))
|
|
179
|
+
} catch {
|
|
180
|
+
return undefined
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function hmac(value, env) {
|
|
185
|
+
if (!env.KENNEL_AUTH_SECRET) throw new Error('Missing KENNEL_AUTH_SECRET.')
|
|
186
|
+
|
|
187
|
+
const key = await crypto.subtle.importKey('raw', encoder.encode(env.KENNEL_AUTH_SECRET), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
|
|
188
|
+
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode(value))
|
|
189
|
+
return base64Url(signature)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function codeDigest(email, code, expires, env) {
|
|
193
|
+
return hmac(`${email}:${code}:${expires}`, env)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function loginPage(url, error = '', email = '', redirect = url.pathname + url.search) {
|
|
197
|
+
return htmlResponse(`<!doctype html>
|
|
198
|
+
<html lang="en">
|
|
199
|
+
<head>
|
|
200
|
+
<meta charset="utf-8">
|
|
201
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
202
|
+
<title>Sign in</title>
|
|
203
|
+
${styles()}
|
|
204
|
+
</head>
|
|
205
|
+
<body>
|
|
206
|
+
<main>
|
|
207
|
+
<p class="eyebrow">Protected docs</p>
|
|
208
|
+
<h1>Sign in with email</h1>
|
|
209
|
+
<p>Enter an approved email address and we will send you a short access code.</p>
|
|
210
|
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
|
|
211
|
+
<form method="post" action="${AUTH_PREFIX}/request">
|
|
212
|
+
<input type="hidden" name="redirect" value="${escapeHtml(redirect)}">
|
|
213
|
+
<label>Email</label>
|
|
214
|
+
<input name="email" type="email" autocomplete="email" required value="${escapeHtml(email)}" autofocus>
|
|
215
|
+
<button type="submit">Send code</button>
|
|
216
|
+
</form>
|
|
217
|
+
</main>
|
|
218
|
+
</body>
|
|
219
|
+
</html>`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function verifyPage(email, redirect, error = '') {
|
|
223
|
+
return `<!doctype html>
|
|
224
|
+
<html lang="en">
|
|
225
|
+
<head>
|
|
226
|
+
<meta charset="utf-8">
|
|
227
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
228
|
+
<title>Enter code</title>
|
|
229
|
+
${styles()}
|
|
230
|
+
</head>
|
|
231
|
+
<body>
|
|
232
|
+
<main>
|
|
233
|
+
<p class="eyebrow">Protected docs</p>
|
|
234
|
+
<h1>Check your email</h1>
|
|
235
|
+
<p>We sent a six-digit code to <strong>${escapeHtml(email)}</strong>.</p>
|
|
236
|
+
${error ? `<p class="error">${escapeHtml(error)}</p>` : ''}
|
|
237
|
+
<form method="post" action="${AUTH_PREFIX}/verify">
|
|
238
|
+
<input type="hidden" name="redirect" value="${escapeHtml(redirect)}">
|
|
239
|
+
<label>Code</label>
|
|
240
|
+
<input name="code" inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code" required autofocus>
|
|
241
|
+
<button type="submit">Continue</button>
|
|
242
|
+
</form>
|
|
243
|
+
</main>
|
|
244
|
+
</body>
|
|
245
|
+
</html>`
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function styles() {
|
|
249
|
+
return `<style>
|
|
250
|
+
:root { color-scheme: light; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
251
|
+
body { min-height: 100vh; margin: 0; display: grid; place-items: center; background: #f7f8f9; color: #172026; }
|
|
252
|
+
main { width: min(100% - 32px, 420px); background: #fff; border: 1px solid #d9dee3; border-radius: 8px; padding: 28px; box-shadow: 0 18px 42px rgb(23 32 38 / 9%); }
|
|
253
|
+
.eyebrow { margin: 0 0 10px; color: #66727f; font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
|
254
|
+
h1 { margin: 0 0 10px; font-size: 26px; line-height: 1.2; letter-spacing: 0; }
|
|
255
|
+
p { margin: 0 0 18px; color: #4b5965; line-height: 1.55; }
|
|
256
|
+
label { display: block; margin: 0 0 8px; font-size: 14px; font-weight: 700; }
|
|
257
|
+
input { box-sizing: border-box; width: 100%; min-height: 44px; border: 1px solid #b8c1ca; border-radius: 6px; padding: 10px 12px; font: inherit; }
|
|
258
|
+
button { width: 100%; min-height: 44px; margin-top: 14px; border: 0; border-radius: 6px; background: #163b56; color: #fff; font: inherit; font-weight: 700; cursor: pointer; }
|
|
259
|
+
.error { padding: 10px 12px; border: 1px solid #e1a4a4; border-radius: 6px; background: #fff4f4; color: #8f1f1f; }
|
|
260
|
+
</style>`
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function redirectResponse(location, headers = {}) {
|
|
264
|
+
return new Response(null, { status: 303, headers: buildHeaders({ Location: location, ...headers }) })
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function htmlResponse(body, headers = {}) {
|
|
268
|
+
return new Response(body, {
|
|
269
|
+
headers: buildHeaders({
|
|
270
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
271
|
+
'Cache-Control': 'no-store',
|
|
272
|
+
...headers,
|
|
273
|
+
}),
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function json(body, status = 200) {
|
|
278
|
+
return new Response(JSON.stringify(body), {
|
|
279
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
280
|
+
status,
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function buildHeaders(input) {
|
|
285
|
+
const headers = new Headers()
|
|
286
|
+
for (const [name, value] of Object.entries(input)) {
|
|
287
|
+
if (Array.isArray(value)) {
|
|
288
|
+
for (const entry of value) headers.append(name, entry)
|
|
289
|
+
continue
|
|
290
|
+
}
|
|
291
|
+
headers.set(name, value)
|
|
292
|
+
}
|
|
293
|
+
return headers
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function cookie(name, value, maxAge, url) {
|
|
297
|
+
const secure = url.protocol === 'https:' ? '; Secure' : ''
|
|
298
|
+
return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseCookies(header) {
|
|
302
|
+
const cookies = new Map()
|
|
303
|
+
for (const part of String(header ?? '').split(';')) {
|
|
304
|
+
const [name, ...value] = part.trim().split('=')
|
|
305
|
+
if (name) cookies.set(name, value.join('='))
|
|
306
|
+
}
|
|
307
|
+
return cookies
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function cleanRedirect(value) {
|
|
311
|
+
const redirect = String(value ?? '/')
|
|
312
|
+
if (!redirect.startsWith('/') || redirect.startsWith('//') || redirect.startsWith(AUTH_PREFIX)) return '/'
|
|
313
|
+
return redirect
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function csv(value) {
|
|
317
|
+
return String(value ?? '')
|
|
318
|
+
.split(',')
|
|
319
|
+
.map((item) => item.trim().toLowerCase())
|
|
320
|
+
.filter(Boolean)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function base64Url(value) {
|
|
324
|
+
const bytes = typeof value === 'string' ? encoder.encode(value) : new Uint8Array(value)
|
|
325
|
+
let binary = ''
|
|
326
|
+
for (const byte of bytes) binary += String.fromCharCode(byte)
|
|
327
|
+
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function fromBase64Url(value) {
|
|
331
|
+
const padded = value.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(value.length / 4) * 4, '=')
|
|
332
|
+
const binary = atob(padded)
|
|
333
|
+
const bytes = new Uint8Array(binary.length)
|
|
334
|
+
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
|
|
335
|
+
return new TextDecoder().decode(bytes)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function escapeHtml(value) {
|
|
339
|
+
return String(value)
|
|
340
|
+
.replace(/&/g, '&')
|
|
341
|
+
.replace(/</g, '<')
|
|
342
|
+
.replace(/>/g, '>')
|
|
343
|
+
.replace(/"/g, '"')
|
|
344
|
+
}
|