kanna-code 0.33.0 → 0.33.1

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.
@@ -31,13 +31,14 @@ function extractCookie(response: Response) {
31
31
  }
32
32
 
33
33
  describe("password auth", () => {
34
- test("redirects unauthenticated html requests to the login page", async () => {
34
+ test("serves the app shell to unauthenticated browser requests", async () => {
35
35
  const { server } = await startPasswordServer()
36
36
 
37
37
  try {
38
- const response = await fetch(`http://localhost:${server.port}/`, { redirect: "manual", headers: { Accept: "text/html" } })
39
- expect(response.status).toBe(302)
40
- expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
38
+ const response = await fetch(`http://localhost:${server.port}/chat/demo`, { headers: { Accept: "text/html" } })
39
+ expect(response.status).toBe(200)
40
+ expect(response.headers.get("content-type")).toContain("text/html")
41
+ expect(await response.text()).toContain('id="root"')
41
42
  } finally {
42
43
  await server.stop()
43
44
  }
@@ -54,14 +55,13 @@ describe("password auth", () => {
54
55
  }
55
56
  })
56
57
 
57
- test("serves the login page without authentication", async () => {
58
+ test("redirects /auth/login back into the app", async () => {
58
59
  const { server } = await startPasswordServer()
59
60
 
60
61
  try {
61
- const response = await fetch(`http://localhost:${server.port}/auth/login`)
62
- expect(response.status).toBe(200)
63
- expect(response.headers.get("content-type")).toContain("text/html")
64
- expect(await response.text()).toContain("This server is password protected")
62
+ const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2Fchat%2Fdemo`, { redirect: "manual" })
63
+ expect(response.status).toBe(302)
64
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/chat/demo`)
65
65
  } finally {
66
66
  await server.stop()
67
67
  }
@@ -71,20 +71,16 @@ describe("password auth", () => {
71
71
  const { server } = await startPasswordServer()
72
72
 
73
73
  try {
74
- const formData = new FormData()
75
- formData.append("password", "secret")
76
- formData.append("next", "/")
77
74
  const response = await fetch(`http://localhost:${server.port}/auth/login`, {
78
75
  method: "POST",
79
- body: formData,
80
- redirect: "manual",
76
+ body: JSON.stringify({ password: "secret", next: "/" }),
81
77
  headers: {
78
+ "Content-Type": "application/json",
82
79
  Origin: `http://localhost:${server.port}`,
83
80
  },
84
81
  })
85
82
 
86
- expect(response.status).toBe(302)
87
- expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
83
+ expect(response.status).toBe(200)
88
84
  expect(extractCookie(response)).toContain("kanna_session=")
89
85
  } finally {
90
86
  await server.stop()
@@ -95,19 +91,16 @@ describe("password auth", () => {
95
91
  const { server } = await startPasswordServer()
96
92
 
97
93
  try {
98
- const formData = new FormData()
99
- formData.append("password", "wrong")
100
94
  const response = await fetch(`http://localhost:${server.port}/auth/login`, {
101
95
  method: "POST",
102
- body: formData,
103
- redirect: "manual",
96
+ body: JSON.stringify({ password: "wrong", next: "/" }),
104
97
  headers: {
98
+ "Content-Type": "application/json",
105
99
  Origin: `http://localhost:${server.port}`,
106
100
  },
107
101
  })
108
102
 
109
- expect(response.status).toBe(302)
110
- expect(response.headers.get("location")).toContain("/auth/login?error=1")
103
+ expect(response.status).toBe(401)
111
104
  expect(response.headers.get("set-cookie")).toBeNull()
112
105
  } finally {
113
106
  await server.stop()
@@ -178,15 +171,14 @@ describe("password auth", () => {
178
171
  const { server } = await startPasswordServer({ port: 54321 })
179
172
 
180
173
  try {
181
- const response = await fetch(`http://localhost:${server.port}/`, {
174
+ const response = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
182
175
  redirect: "manual",
183
176
  headers: {
184
- Accept: "text/html",
185
177
  "X-Forwarded-Proto": "https",
186
178
  },
187
179
  })
188
180
  expect(response.status).toBe(302)
189
- expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
181
+ expect(response.headers.get("location")).toBe(`http://localhost:${server.port}/`)
190
182
 
191
183
  const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
192
184
  method: "POST",
@@ -220,15 +212,14 @@ describe("password auth", () => {
220
212
  const { server } = await startPasswordServer({ port: 54322, trustProxy: true })
221
213
 
222
214
  try {
223
- const redirect = await fetch(`http://localhost:${server.port}/`, {
215
+ const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
224
216
  redirect: "manual",
225
217
  headers: {
226
- Accept: "text/html",
227
218
  "X-Forwarded-Proto": "https",
228
219
  },
229
220
  })
230
221
  expect(redirect.status).toBe(302)
231
- expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}/auth/login?next=%2F`)
222
+ expect(redirect.headers.get("location")).toBe(`https://localhost:${server.port}/`)
232
223
 
233
224
  const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
234
225
  method: "POST",
@@ -260,15 +251,14 @@ describe("password auth", () => {
260
251
  const { server } = await startPasswordServer({ port: 54323, trustProxy: true })
261
252
 
262
253
  try {
263
- const redirect = await fetch(`http://localhost:${server.port}/`, {
254
+ const redirect = await fetch(`http://localhost:${server.port}/auth/login?next=%2F`, {
264
255
  redirect: "manual",
265
256
  headers: {
266
- Accept: "text/html",
267
257
  "X-Forwarded-Proto": "ftp",
268
258
  },
269
259
  })
270
260
  expect(redirect.status).toBe(302)
271
- expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}/auth/login?next=%2F`)
261
+ expect(redirect.headers.get("location")).toBe(`http://localhost:${server.port}/`)
272
262
 
273
263
  const loginResponse = await fetch(`http://localhost:${server.port}/auth/login`, {
274
264
  method: "POST",
@@ -1,5 +1,4 @@
1
1
  import { randomBytes, timingSafeEqual } from "node:crypto"
2
- import { APP_NAME } from "../shared/branding"
3
2
 
4
3
  const SESSION_COOKIE_NAME = "kanna_session"
5
4
 
@@ -9,17 +8,12 @@ export interface AuthStatusPayload {
9
8
  }
10
9
 
11
10
  export interface AuthManager {
12
- readonly enabled: true
13
11
  isAuthenticated(req: Request): boolean
14
12
  validateOrigin(req: Request): boolean
15
- createSessionCookie(req: Request): string
16
- clearSessionCookie(req: Request): string
17
- verifyPassword(candidate: string): boolean
13
+ redirectToApp(req: Request): Response
18
14
  handleLogin(req: Request, nextPath: string): Promise<Response>
19
15
  handleLogout(req: Request): Response
20
16
  handleStatus(req: Request): Response
21
- renderLoginPage(req: Request): Response
22
- unauthorizedResponse(req: Request): Response
23
17
  }
24
18
 
25
19
  function parseCookies(header: string | null) {
@@ -94,7 +88,6 @@ async function readLoginForm(req: Request) {
94
88
  return {
95
89
  password: typeof payload.password === "string" ? payload.password : "",
96
90
  nextPath: sanitizeNextPath(typeof payload.next === "string" ? payload.next : "/"),
97
- wantsJson: true,
98
91
  }
99
92
  }
100
93
 
@@ -102,24 +95,9 @@ async function readLoginForm(req: Request) {
102
95
  return {
103
96
  password: String(formData.get("password") ?? ""),
104
97
  nextPath: sanitizeNextPath(String(formData.get("next") ?? "/")),
105
- wantsJson: false,
106
98
  }
107
99
  }
108
100
 
109
- function requestWantsHtml(req: Request) {
110
- const accept = req.headers.get("accept") ?? ""
111
- return accept.includes("text/html")
112
- }
113
-
114
- function escapeHtml(value: string) {
115
- return value
116
- .replaceAll("&", "&amp;")
117
- .replaceAll("<", "&lt;")
118
- .replaceAll(">", "&gt;")
119
- .replaceAll("\"", "&quot;")
120
- .replaceAll("'", "&#39;")
121
- }
122
-
123
101
  export interface AuthManagerOptions {
124
102
  /**
125
103
  * When true, the auth layer trusts X-Forwarded-Proto to decide whether the
@@ -184,108 +162,9 @@ export function createAuthManager(password: string, options: AuthManagerOptions
184
162
  } satisfies AuthStatusPayload)
185
163
  }
186
164
 
187
- function unauthorizedResponse(req: Request) {
188
- if (req.method === "GET" && requestWantsHtml(req)) {
189
- const url = new URL(req.url)
190
- const loginUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
191
- loginUrl.searchParams.set("next", sanitizeNextPath(`${url.pathname}${url.search}`))
192
- return Response.redirect(loginUrl, 302)
193
- }
194
-
195
- return Response.json({ error: "Unauthorized" }, { status: 401 })
196
- }
197
-
198
- function renderLoginPage(req: Request) {
199
- if (isAuthenticated(req)) {
200
- const currentUrl = new URL(req.url)
201
- return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
202
- }
203
-
165
+ function redirectToApp(req: Request) {
204
166
  const currentUrl = new URL(req.url)
205
- const nextPath = sanitizeNextPath(currentUrl.searchParams.get("next"))
206
- const showError = currentUrl.searchParams.get("error") === "1"
207
- const html = `<!doctype html>
208
- <html lang="en">
209
- <head>
210
- <meta charset="utf-8" />
211
- <meta name="viewport" content="width=device-width, initial-scale=1" />
212
- <title>${escapeHtml(APP_NAME)} Login</title>
213
- <style>
214
- :root { color-scheme: dark; }
215
- * { box-sizing: border-box; }
216
- body {
217
- margin: 0;
218
- min-height: 100vh;
219
- font-family: ui-sans-serif, system-ui, sans-serif;
220
- background:
221
- radial-gradient(circle at top, rgba(255,255,255,0.10), transparent 28%),
222
- linear-gradient(160deg, #16181d 0%, #0b0d10 55%, #050608 100%);
223
- color: #f4f7fb;
224
- display: grid;
225
- place-items: center;
226
- padding: 24px;
227
- }
228
- .card {
229
- width: min(420px, 100%);
230
- border: 1px solid rgba(255,255,255,0.12);
231
- background: rgba(12, 15, 20, 0.88);
232
- backdrop-filter: blur(10px);
233
- border-radius: 24px;
234
- padding: 28px;
235
- box-shadow: 0 24px 70px rgba(0,0,0,0.35);
236
- }
237
- h1 { margin: 0 0 8px; font-size: 28px; }
238
- p { margin: 0 0 20px; color: #a6b0bd; line-height: 1.5; }
239
- label { display: block; font-size: 13px; margin-bottom: 8px; color: #d7dce4; }
240
- input {
241
- width: 100%;
242
- border-radius: 14px;
243
- border: 1px solid rgba(255,255,255,0.16);
244
- background: rgba(255,255,255,0.04);
245
- color: inherit;
246
- padding: 14px 16px;
247
- font-size: 15px;
248
- margin-bottom: 16px;
249
- }
250
- button {
251
- width: 100%;
252
- border: 0;
253
- border-radius: 14px;
254
- padding: 14px 16px;
255
- background: linear-gradient(135deg, #f4ede0 0%, #d9c4a1 100%);
256
- color: #16181d;
257
- font-size: 15px;
258
- font-weight: 700;
259
- cursor: pointer;
260
- }
261
- .error {
262
- margin-bottom: 16px;
263
- border-radius: 14px;
264
- background: rgba(255, 106, 106, 0.12);
265
- border: 1px solid rgba(255, 106, 106, 0.24);
266
- color: #ffb8b8;
267
- padding: 12px 14px;
268
- font-size: 14px;
269
- }
270
- </style>
271
- </head>
272
- <body>
273
- <form class="card" method="post" action="/auth/login">
274
- <h1>${escapeHtml(APP_NAME)}</h1>
275
- <p>This server is password protected. Enter the launch password to continue.</p>
276
- ${showError ? '<div class="error">Incorrect password. Try again.</div>' : ""}
277
- <input type="hidden" name="next" value="${escapeHtml(nextPath)}" />
278
- <label for="password">Password</label>
279
- <input id="password" name="password" type="password" autocomplete="current-password" autofocus required />
280
- <button type="submit">Unlock</button>
281
- </form>
282
- </body>
283
- </html>`
284
- return new Response(html, {
285
- headers: {
286
- "Content-Type": "text/html; charset=utf-8",
287
- },
288
- })
167
+ return Response.redirect(new URL(sanitizeNextPath(currentUrl.searchParams.get("next")), effectiveOrigin(req, trustProxy)), 302)
289
168
  }
290
169
 
291
170
  async function handleLogin(req: Request, fallbackNextPath: string) {
@@ -293,21 +172,12 @@ export function createAuthManager(password: string, options: AuthManagerOptions
293
172
  return Response.json({ error: "Forbidden" }, { status: 403 })
294
173
  }
295
174
 
296
- const { password: candidate, nextPath, wantsJson } = await readLoginForm(req)
175
+ const { password: candidate, nextPath } = await readLoginForm(req)
297
176
  if (!verifyPassword(candidate)) {
298
- if (wantsJson) {
299
- return Response.json({ error: "Invalid password" }, { status: 401 })
300
- }
301
-
302
- const redirectUrl = new URL("/auth/login", effectiveOrigin(req, trustProxy))
303
- redirectUrl.searchParams.set("error", "1")
304
- redirectUrl.searchParams.set("next", sanitizeNextPath(nextPath || fallbackNextPath))
305
- return Response.redirect(redirectUrl, 302)
177
+ return Response.json({ error: "Invalid password" }, { status: 401 })
306
178
  }
307
179
 
308
- const response = wantsJson
309
- ? Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
310
- : Response.redirect(new URL(sanitizeNextPath(nextPath || fallbackNextPath), effectiveOrigin(req, trustProxy)), 302)
180
+ const response = Response.json({ ok: true, nextPath: sanitizeNextPath(nextPath || fallbackNextPath) })
311
181
 
312
182
  response.headers.set("Set-Cookie", createSessionCookie(req))
313
183
  return response
@@ -324,16 +194,11 @@ export function createAuthManager(password: string, options: AuthManagerOptions
324
194
  }
325
195
 
326
196
  return {
327
- enabled: true,
328
197
  isAuthenticated,
329
198
  validateOrigin,
330
- createSessionCookie,
331
- clearSessionCookie,
332
- verifyPassword,
199
+ redirectToApp,
333
200
  handleLogin,
334
201
  handleLogout,
335
202
  handleStatus,
336
- renderLoginPage,
337
- unauthorizedResponse,
338
203
  }
339
204
  }
@@ -175,7 +175,7 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
175
175
  if (auth) {
176
176
  if (url.pathname === "/auth/login") {
177
177
  if (req.method === "GET") {
178
- return auth.renderLoginPage(req)
178
+ return auth.redirectToApp(req)
179
179
  }
180
180
  if (req.method === "POST") {
181
181
  return auth.handleLogin(req, "/")
@@ -190,8 +190,8 @@ export async function startKannaServer(options: StartKannaServerOptions = {}) {
190
190
  if (!auth.isAuthenticated(req)) {
191
191
  return new Response("Unauthorized", { status: 401 })
192
192
  }
193
- } else if (!auth.isAuthenticated(req)) {
194
- return auth.unauthorizedResponse(req)
193
+ } else if ((url.pathname === "/health" || url.pathname.startsWith("/api/")) && !auth.isAuthenticated(req)) {
194
+ return Response.json({ error: "Unauthorized" }, { status: 401 })
195
195
  }
196
196
  }
197
197