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.
Files changed (77) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +203 -0
  3. package/bin/kennel.mjs +3 -0
  4. package/dist/automations/notifications.d.ts +40 -0
  5. package/dist/automations/notifications.js +220 -0
  6. package/dist/cli/dispatch.d.ts +1 -0
  7. package/dist/cli/dispatch.js +51 -0
  8. package/dist/cli/usage.d.ts +1 -0
  9. package/dist/cli/usage.js +14 -0
  10. package/dist/deploy/cloudflare.d.ts +6 -0
  11. package/dist/deploy/cloudflare.js +91 -0
  12. package/dist/deploy/environment.d.ts +2 -0
  13. package/dist/deploy/environment.js +47 -0
  14. package/dist/deploy/smoke-tests.d.ts +3 -0
  15. package/dist/deploy/smoke-tests.js +71 -0
  16. package/dist/deploy/trigger-dev.d.ts +2 -0
  17. package/dist/deploy/trigger-dev.js +255 -0
  18. package/dist/main.d.ts +1 -0
  19. package/dist/main.js +8 -0
  20. package/dist/mcp/agent-tools.d.ts +8 -0
  21. package/dist/mcp/agent-tools.js +247 -0
  22. package/dist/mcp/config.d.ts +25 -0
  23. package/dist/mcp/config.js +171 -0
  24. package/dist/mcp/data.d.ts +14 -0
  25. package/dist/mcp/data.js +120 -0
  26. package/dist/mcp/function-source.d.ts +7 -0
  27. package/dist/mcp/function-source.js +40 -0
  28. package/dist/mcp/generate.d.ts +1 -0
  29. package/dist/mcp/generate.js +47 -0
  30. package/dist/mcp/install-page.d.ts +1 -0
  31. package/dist/mcp/install-page.js +360 -0
  32. package/dist/notifications/email-worker.d.ts +8 -0
  33. package/dist/notifications/email-worker.js +137 -0
  34. package/dist/package/mcp-data.d.ts +1 -0
  35. package/dist/package/mcp-data.js +1 -0
  36. package/dist/package/mcp-email-worker.d.ts +1 -0
  37. package/dist/package/mcp-email-worker.js +1 -0
  38. package/dist/package/mcp-http.d.ts +1 -0
  39. package/dist/package/mcp-http.js +1 -0
  40. package/dist/package/mcp-stdio.d.ts +1 -0
  41. package/dist/package/mcp-stdio.js +1 -0
  42. package/dist/package/mcp.d.ts +1 -0
  43. package/dist/package/mcp.js +1 -0
  44. package/dist/publish/cloudflare-init.d.ts +12 -0
  45. package/dist/publish/cloudflare-init.js +98 -0
  46. package/dist/publish/config.d.ts +28 -0
  47. package/dist/publish/config.js +190 -0
  48. package/dist/publish/options.d.ts +13 -0
  49. package/dist/publish/options.js +78 -0
  50. package/dist/schema/doctor.d.ts +1 -0
  51. package/dist/schema/doctor.js +69 -0
  52. package/dist/schema/documents.d.ts +18 -0
  53. package/dist/schema/documents.js +127 -0
  54. package/dist/schema/pointers.d.ts +5 -0
  55. package/dist/schema/pointers.js +48 -0
  56. package/dist/schema/types.d.ts +36 -0
  57. package/dist/schema/types.js +1 -0
  58. package/dist/schema/validation.d.ts +8 -0
  59. package/dist/schema/validation.js +212 -0
  60. package/dist/shared/json.d.ts +4 -0
  61. package/dist/shared/json.js +56 -0
  62. package/dist/shared/object.d.ts +2 -0
  63. package/dist/shared/object.js +3 -0
  64. package/dist/shared/package-manager.d.ts +11 -0
  65. package/dist/shared/package-manager.js +39 -0
  66. package/dist/shared/path.d.ts +4 -0
  67. package/dist/shared/path.js +40 -0
  68. package/dist/shared/process.d.ts +7 -0
  69. package/dist/shared/process.js +12 -0
  70. package/dist/shared/project-config.d.ts +2 -0
  71. package/dist/shared/project-config.js +26 -0
  72. package/dist/site/dev.d.ts +10 -0
  73. package/dist/site/dev.js +128 -0
  74. package/dist/site/vitepress.d.ts +21 -0
  75. package/dist/site/vitepress.js +66 -0
  76. package/package.json +74 -0
  77. 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, '&amp;')
341
+ .replace(/</g, '&lt;')
342
+ .replace(/>/g, '&gt;')
343
+ .replace(/"/g, '&quot;')
344
+ }