From 1b517f44e288964e85fdef0f20d185bef83fb1bd Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Sat, 25 Jul 2026 00:31:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(tui):=20=E6=B7=BB=E5=8A=A0=20M0A.5b=20?= =?UTF-8?q?TUI=20=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接入正式认证登录/会话恢复,并按复审加固: - 认证请求 redirect:manual,逐跳拒绝 HTTPS→HTTP 与跨 origin - AbortController 覆盖完整 body 读取;session 隔离/重置与保存同锁,锁支持过期回收 - CSRF 403 若 jar token 已更新则直接重试;session-invalid 去重先于清理副作用 基于 main reset 后重提原 #84,rebase 到 DataLink 外置 deploy + M0A.5a 栈上; 同步清理 TUI 指南中残留的离线 Demo 模式表述。 Co-authored-by: wing --- .github/workflows/ci.yml | 6 + apps/tui/README.md | 2 +- apps/tui/package.json | 2 +- apps/tui/src/auth/auth-client.test.ts | 278 +++++++++++ apps/tui/src/auth/auth-client.ts | 373 ++++++++++++++ .../src/auth/authenticated-transport.test.ts | 394 +++++++++++++++ apps/tui/src/auth/authenticated-transport.ts | 202 ++++++++ apps/tui/src/auth/bootstrap.test.ts | 296 +++++++++++ apps/tui/src/auth/bootstrap.ts | 255 ++++++++++ apps/tui/src/auth/browser-opener.test.ts | 109 ++++ apps/tui/src/auth/browser-opener.ts | 76 +++ apps/tui/src/auth/cookie-jar.test.ts | 62 +++ apps/tui/src/auth/cookie-jar.ts | 69 +++ apps/tui/src/auth/index.ts | 33 ++ apps/tui/src/auth/interactive-login.test.ts | 221 +++++++++ apps/tui/src/auth/interactive-login.ts | 251 ++++++++++ apps/tui/src/auth/session-store.test.ts | 196 ++++++++ apps/tui/src/auth/session-store.ts | 415 ++++++++++++++++ apps/tui/src/auth/types.ts | 38 ++ apps/tui/src/commands/builtinCommands.ts | 13 + apps/tui/src/commands/logout-command.test.ts | 18 + apps/tui/src/commands/types.ts | 2 + apps/tui/src/config/config-client.test.ts | 36 ++ apps/tui/src/config/config-client.ts | 15 +- apps/tui/src/index-auth-wiring.test.ts | 387 +++++++++++++++ apps/tui/src/index.tsx | 464 +++++++++++++----- apps/tui/src/no-bare-fetch.guard.test.ts | 110 +++++ apps/tui/src/protocol/client.ts | 1 - .../protocol/copilotkit-client-auth.test.ts | 35 ++ apps/tui/src/protocol/copilotkit-client.ts | 7 +- apps/tui/src/protocol/demo-client.ts | 173 ------- apps/tui/src/protocol/index.ts | 1 - apps/tui/src/runtime-url.test.ts | 60 +++ apps/tui/src/runtime-url.ts | 63 +++ apps/tui/src/startup-preflight.test.ts | 84 ++++ apps/tui/src/startup-preflight.ts | 110 +++++ apps/tui/src/state/demo-state.ts | 101 ---- apps/tui/src/ui/App.tsx | 108 +++- docs/en/capabilities.md | 2 +- docs/en/guides/tui.md | 8 +- docs/en/quick-start.md | 4 +- docs/zh/capabilities.md | 2 +- docs/zh/guides/tui.md | 6 +- docs/zh/quick-start.md | 4 +- package.json | 1 + scripts/auth-foundation.test.mjs | 1 + scripts/smoke-tui-auth-sharing.mjs | 143 ++++++ 47 files changed, 4800 insertions(+), 437 deletions(-) create mode 100644 apps/tui/src/auth/auth-client.test.ts create mode 100644 apps/tui/src/auth/auth-client.ts create mode 100644 apps/tui/src/auth/authenticated-transport.test.ts create mode 100644 apps/tui/src/auth/authenticated-transport.ts create mode 100644 apps/tui/src/auth/bootstrap.test.ts create mode 100644 apps/tui/src/auth/bootstrap.ts create mode 100644 apps/tui/src/auth/browser-opener.test.ts create mode 100644 apps/tui/src/auth/browser-opener.ts create mode 100644 apps/tui/src/auth/cookie-jar.test.ts create mode 100644 apps/tui/src/auth/cookie-jar.ts create mode 100644 apps/tui/src/auth/index.ts create mode 100644 apps/tui/src/auth/interactive-login.test.ts create mode 100644 apps/tui/src/auth/interactive-login.ts create mode 100644 apps/tui/src/auth/session-store.test.ts create mode 100644 apps/tui/src/auth/session-store.ts create mode 100644 apps/tui/src/auth/types.ts create mode 100644 apps/tui/src/commands/logout-command.test.ts create mode 100644 apps/tui/src/config/config-client.test.ts create mode 100644 apps/tui/src/index-auth-wiring.test.ts create mode 100644 apps/tui/src/no-bare-fetch.guard.test.ts create mode 100644 apps/tui/src/protocol/copilotkit-client-auth.test.ts delete mode 100644 apps/tui/src/protocol/demo-client.ts create mode 100644 apps/tui/src/runtime-url.test.ts create mode 100644 apps/tui/src/runtime-url.ts create mode 100644 apps/tui/src/startup-preflight.test.ts create mode 100644 apps/tui/src/startup-preflight.ts delete mode 100644 apps/tui/src/state/demo-state.ts create mode 100644 scripts/smoke-tui-auth-sharing.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c0de59fe..343202c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Run Web tests run: npm run test:web + - name: Run TUI unit tests + run: npm --workspace @datafoundry/tui test + - name: Build Web application run: npm run build:web @@ -84,6 +87,9 @@ jobs: - name: Run authentication regression run: node scripts/smoke-auth.mjs + - name: Run TUI/Web auth sharing regression + run: npm run smoke:tui-auth-sharing + - name: Run conversation memory regression run: node scripts/smoke-conversation-memory.mjs diff --git a/apps/tui/README.md b/apps/tui/README.md index 1f0c32d0..dfc972ff 100644 --- a/apps/tui/README.md +++ b/apps/tui/README.md @@ -11,5 +11,5 @@ From the repository root: npm install npm run start:tui npm run start:tui -- --runtime-url http://127.0.0.1:8787/api/copilotkit -npm run start:tui -- --demo +npm run start:tui -- --no-auto-login ``` diff --git a/apps/tui/package.json b/apps/tui/package.json index b3b86daa..7c8a0187 100644 --- a/apps/tui/package.json +++ b/apps/tui/package.json @@ -14,7 +14,7 @@ "dev": "tsc --watch", "prestart": "npm run build", "start": "node dist/index.js", - "test": "npm run build && node --test dist/ui/components/EnhancedInputBox.test.js", + "test": "npm run build && node --test dist/auth/cookie-jar.test.js dist/auth/session-store.test.js dist/auth/authenticated-transport.test.js dist/auth/auth-client.test.js dist/auth/bootstrap.test.js dist/auth/browser-opener.test.js dist/auth/interactive-login.test.js dist/config/config-client.test.js dist/protocol/copilotkit-client-auth.test.js dist/startup-preflight.test.js dist/runtime-url.test.js dist/index-auth-wiring.test.js dist/commands/logout-command.test.js dist/no-bare-fetch.guard.test.js dist/ui/components/EnhancedInputBox.test.js", "clean": "rm -rf dist" }, "keywords": [ diff --git a/apps/tui/src/auth/auth-client.test.ts b/apps/tui/src/auth/auth-client.test.ts new file mode 100644 index 00000000..1ca78130 --- /dev/null +++ b/apps/tui/src/auth/auth-client.test.ts @@ -0,0 +1,278 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { assertSafeAuthRedirect, TuiAuthClient, TuiAuthError } from "./auth-client.js"; +import { TuiCookieJar } from "./cookie-jar.js"; + +describe("TuiAuthClient", () => { + it("calls status/login/me/csrf/logout with expected URLs and methods", async () => { + const jar = new TuiCookieJar(); + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + const client = new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: jar, + fetchImpl: async (input, init) => { + const url = String(input); + const method = String(init?.method ?? "GET"); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, method, body }); + if (url.endsWith("/api/v1/auth/status")) { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: true }, + }); + } + if (url.endsWith("/api/v1/auth/login")) { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "a@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + [ + "df_session=sess; Path=/; HttpOnly", + "df_csrf=csrf; Path=/", + ], + ); + } + if (url.endsWith("/api/v1/me")) { + return json(200, { + success: true, + data: { user: { id: "u1", email: "a@example.com" }, workspace: { id: "w1" } }, + }); + } + if (url.endsWith("/api/v1/auth/csrf/refresh")) { + return json(200, { success: true, data: { csrfToken: "new" } }, ["df_csrf=new; Path=/"]); + } + if (url.endsWith("/api/v1/auth/logout")) { + return json(200, { success: true, data: { ok: true } }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "missing" } }); + }, + }); + + await client.getStatus(); + await client.login("a@example.com", "password"); + await client.me(); + await client.refreshCsrf(); + await client.logout(); + + assert.deepEqual( + calls.map((call) => ({ method: call.method, path: new URL(call.url).pathname })), + [ + { method: "GET", path: "/api/v1/auth/status" }, + { method: "POST", path: "/api/v1/auth/login" }, + { method: "GET", path: "/api/v1/me" }, + { method: "POST", path: "/api/v1/auth/csrf/refresh" }, + { method: "POST", path: "/api/v1/auth/logout" }, + ], + ); + assert.equal((calls[1]?.body as { client?: string } | undefined)?.client, "tui"); + assert.equal( + calls.some((call) => call.url.includes("/api/v1/auth/me")), + false, + ); + }); + + it("requires session.expiresAt from login response", async () => { + const client = new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: new TuiCookieJar(), + fetchImpl: async () => + json(200, { + success: true, + data: { + user: { id: "u1", email: "a@example.com" }, + workspace: { id: "w1" }, + session: {}, + }, + }), + }); + + await assert.rejects( + () => client.login("a@example.com", "password"), + (error: unknown) => error instanceof TuiAuthError && error.code === "INVALID_LOGIN_RESPONSE", + ); + }); + + it("times out hung auth requests", async () => { + const client = new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: new TuiCookieJar(), + timeoutMs: 20, + fetchImpl: async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + reject(error); + }); + }), + }); + + await assert.rejects( + () => client.getStatus(), + (error: unknown) => error instanceof TuiAuthError && error.code === "TIMEOUT", + ); + }); + + it("maps transport failures to NETWORK_ERROR", async () => { + const client = new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: new TuiCookieJar(), + fetchImpl: async () => { + throw new TypeError("fetch failed"); + }, + }); + + await assert.rejects( + () => client.getStatus(), + (error: unknown) => error instanceof TuiAuthError && error.code === "NETWORK_ERROR", + ); + }); + + it("uses redirect:manual and rejects HTTPS→HTTP auth redirects", async () => { + const seen: Array<{ redirect?: RequestRedirect; url: string }> = []; + const client = new TuiAuthClient({ + apiBaseUrl: "https://api.example.com", + cookieJar: new TuiCookieJar(), + fetchImpl: async (input, init) => { + seen.push({ + url: String(input), + ...(init?.redirect ? { redirect: init.redirect } : {}), + }); + return new Response(null, { + status: 308, + headers: { location: "http://api.example.com/api/v1/auth/login" }, + }); + }, + }); + + await assert.rejects( + () => client.login("a@example.com", "password"), + (error: unknown) => + error instanceof TuiAuthError + && error.code === "UNSAFE_REDIRECT" + && /HTTPS to HTTP/i.test(error.message), + ); + assert.equal(seen[0]?.redirect, "manual"); + assert.equal(seen.length, 1); + }); + + it("rejects cross-origin auth redirects and does not forward the password body", async () => { + let bodies = 0; + const client = new TuiAuthClient({ + apiBaseUrl: "https://api.example.com", + cookieJar: new TuiCookieJar(), + fetchImpl: async (_input, init) => { + if (init?.body) { + bodies += 1; + } + return new Response(null, { + status: 307, + headers: { location: "https://evil.example.com/api/v1/auth/login" }, + }); + }, + }); + + await assert.rejects( + () => client.login("a@example.com", "password"), + (error: unknown) => + error instanceof TuiAuthError + && error.code === "UNSAFE_REDIRECT" + && /cross-origin/i.test(error.message), + ); + assert.equal(bodies, 1); + }); + + it("follows same-origin 307 and keeps timeout armed through body parse", async () => { + let fetches = 0; + let bodyReadStarted = false; + let abortedDuringBody = false; + const client = new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: new TuiCookieJar(), + timeoutMs: 40, + fetchImpl: async (input, init) => { + fetches += 1; + assert.equal(init?.redirect, "manual"); + if (fetches === 1) { + return new Response(null, { + status: 307, + headers: { location: "http://127.0.0.1:8787/api/v1/auth/status" }, + }); + } + const payload = JSON.stringify({ + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + return new Response( + new ReadableStream({ + start(controller) { + bodyReadStarted = true; + const timer = setTimeout(() => { + controller.enqueue(new TextEncoder().encode(payload)); + controller.close(); + }, 80); + init?.signal?.addEventListener("abort", () => { + abortedDuringBody = true; + clearTimeout(timer); + try { + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" })); + } catch { + // already closed + } + }); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }); + + await assert.rejects( + () => client.getStatus(), + (error: unknown) => error instanceof TuiAuthError && error.code === "TIMEOUT", + ); + assert.equal(fetches, 2); + assert.equal(bodyReadStarted, true); + assert.equal(abortedDuringBody, true); + }); +}); + +describe("assertSafeAuthRedirect", () => { + it("allows same-origin HTTPS hops and rejects downgrade/cross-origin", () => { + assert.equal( + assertSafeAuthRedirect( + "https://api.example.com/api/v1/auth/login", + "/api/v1/auth/login?next=1", + ), + "https://api.example.com/api/v1/auth/login?next=1", + ); + assert.throws( + () => assertSafeAuthRedirect( + "https://api.example.com/login", + "http://api.example.com/login", + ), + (error: unknown) => error instanceof TuiAuthError && error.code === "UNSAFE_REDIRECT", + ); + assert.throws( + () => assertSafeAuthRedirect( + "https://api.example.com/login", + "https://other.example.com/login", + ), + (error: unknown) => error instanceof TuiAuthError && error.code === "UNSAFE_REDIRECT", + ); + }); +}); + +function json(status: number, body: unknown, setCookies: string[] = []): Response { + const headers = new Headers({ "content-type": "application/json" }); + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/apps/tui/src/auth/auth-client.ts b/apps/tui/src/auth/auth-client.ts new file mode 100644 index 00000000..35e68434 --- /dev/null +++ b/apps/tui/src/auth/auth-client.ts @@ -0,0 +1,373 @@ +import { isLoopbackHostname } from "../runtime-url.js"; +import { TuiCookieJar } from "./cookie-jar.js"; +import { normalizeApiBaseUrl } from "./session-store.js"; +import type { AuthStatus, StoredTuiSession, TuiUser, TuiWorkspace } from "./types.js"; + +export const DEFAULT_AUTH_TIMEOUT_MS = 15_000; +const MAX_AUTH_REDIRECTS = 5; +const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); + +export type TuiAuthClientOptions = { + apiBaseUrl: string; + cookieJar: TuiCookieJar; + fetchImpl?: typeof fetch; + timeoutMs?: number; +}; + +export class TuiAuthError extends Error { + readonly status: number; + readonly code: string; + + constructor(status: number, code: string, message: string) { + super(message); + this.name = "TuiAuthError"; + this.status = status; + this.code = code; + } +} + +export class TuiAuthClient { + readonly apiBaseUrl: string; + readonly cookieJar: TuiCookieJar; + readonly fetchImpl: typeof fetch; + readonly timeoutMs: number; + + constructor(options: TuiAuthClientOptions) { + this.apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl); + this.cookieJar = options.cookieJar; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.timeoutMs = options.timeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; + } + + async getStatus(): Promise { + const body = await this.requestJson("GET", "/api/v1/auth/status"); + const data = asRecord(body.data); + const publicBaseUrl = asString(data?.publicBaseUrl); + if (!publicBaseUrl) { + throw new TuiAuthError(500, "INVALID_STATUS", "Auth status missing publicBaseUrl."); + } + return { + publicBaseUrl, + registrationEnabled: Boolean(data?.registrationEnabled), + }; + } + + async login(email: string, password: string): Promise { + const { response, json: body } = await this.requestRaw("POST", "/api/v1/auth/login", { + email, + password, + client: "tui", + }); + if (!response.ok) { + throw errorFromBody(response.status, body); + } + + const data = asRecord(body.data); + const user = parseUser(data?.user); + const workspace = parseWorkspace(data?.workspace); + const session = asRecord(data?.session); + const expiresAt = asString(session?.expiresAt); + if (!expiresAt || Number.isNaN(Date.parse(expiresAt))) { + throw new TuiAuthError( + 500, + "INVALID_LOGIN_RESPONSE", + "Login response missing session.expiresAt.", + ); + } + + return { + apiBaseUrl: this.apiBaseUrl, + cookies: this.cookieJar.snapshot(), + user, + workspace, + expiresAt, + }; + } + + async me(): Promise { + const body = await this.requestJson("GET", "/api/v1/me"); + const data = asRecord(body.data); + const user = parseUser(data?.user); + const workspace = parseWorkspace(data?.workspace); + return { ...user, workspace }; + } + + async refreshCsrf(): Promise { + const { response, json } = await this.requestRaw("POST", "/api/v1/auth/csrf/refresh"); + if (!response.ok) { + throw errorFromBody(response.status, json); + } + } + + async logout(): Promise { + const { response, json } = await this.requestRaw("POST", "/api/v1/auth/logout"); + if (!response.ok && response.status !== 401) { + throw errorFromBody(response.status, json); + } + this.cookieJar.clear(); + } + + private async requestJson( + method: string, + path: string, + body?: unknown, + ): Promise<{ data?: unknown; error?: { code?: string; message?: string } }> { + const { response, json } = await this.requestRaw(method, path, body); + if (!response.ok) { + throw errorFromBody(response.status, json); + } + return json; + } + + /** + * Fetch + JSON parse under one AbortController so timeouts cover the full body read. + * Auth requests never auto-follow redirects; hops are validated manually. + */ + private async requestRaw( + method: string, + path: string, + body?: unknown, + ): Promise<{ + response: Response; + json: { data?: unknown; error?: { code?: string; message?: string } }; + }> { + return this.withTimeout(async (signal) => { + const response = await this.performFetch(method, path, body, signal); + this.cookieJar.absorbSetCookie(response.headers); + const json = await readJson(response); + return { response, json }; + }); + } + + private async withTimeout(fn: (signal: AbortSignal) => Promise): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeoutMs); + try { + return await fn(controller.signal); + } catch (error) { + if (error instanceof TuiAuthError) { + throw error; + } + if (isAbortError(error)) { + throw new TuiAuthError( + 0, + "TIMEOUT", + `Auth request timed out after ${this.timeoutMs}ms`, + ); + } + const message = error instanceof Error ? error.message : String(error); + throw new TuiAuthError(0, "NETWORK_ERROR", message || "Network request failed"); + } finally { + clearTimeout(timeoutId); + } + } + + private async performFetch( + method: string, + path: string, + body: unknown | undefined, + signal: AbortSignal, + ): Promise { + let url = resolveApiUrl(this.apiBaseUrl, path); + let currentMethod = method; + let currentBody = body; + for (let hop = 0; hop <= MAX_AUTH_REDIRECTS; hop += 1) { + const headers = new Headers(); + const cookieHeader = this.cookieJar.headerValue(); + if (cookieHeader) { + headers.set("cookie", cookieHeader); + } + if (currentMethod !== "GET" && currentMethod !== "HEAD") { + headers.set("content-type", "application/json"); + const csrf = this.cookieJar.csrfToken(); + if (csrf) { + headers.set("x-csrf-token", csrf); + } + } + + const response = await this.fetchImpl(url, { + method: currentMethod, + headers, + signal, + redirect: "manual", + ...(currentBody !== undefined ? { body: JSON.stringify(currentBody) } : {}), + }); + + if (!REDIRECT_STATUSES.has(response.status)) { + return response; + } + + if (hop === MAX_AUTH_REDIRECTS) { + throw new TuiAuthError( + response.status, + "UNSAFE_REDIRECT", + "Auth request exceeded redirect hop limit.", + ); + } + + const nextUrl = assertSafeAuthRedirect(url, response.headers.get("location")); + // Absorb cookies from redirect responses before following. + this.cookieJar.absorbSetCookie(response.headers); + + if (response.status === 301 || response.status === 302 || response.status === 303) { + if (currentMethod !== "GET" && currentMethod !== "HEAD") { + throw new TuiAuthError( + response.status, + "UNSAFE_REDIRECT", + "Refusing method-changing auth redirect for non-GET request.", + ); + } + currentMethod = "GET"; + currentBody = undefined; + } + url = nextUrl; + } + + throw new TuiAuthError(0, "UNSAFE_REDIRECT", "Auth request exceeded redirect hop limit."); + } +} + +export function assertSafeAuthRedirect(fromUrl: string, location: string | null): string { + if (!location?.trim()) { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Auth redirect missing Location header.", + ); + } + + let from: URL; + let next: URL; + try { + from = new URL(fromUrl); + next = new URL(location, from); + } catch { + throw new TuiAuthError(0, "UNSAFE_REDIRECT", "Auth redirect Location is not a valid URL."); + } + + if (next.username || next.password) { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Auth redirect must not include credentials.", + ); + } + + if (next.protocol !== "http:" && next.protocol !== "https:") { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Auth redirect must use http:// or https://.", + ); + } + + // Protocol is part of origin — check downgrade before the generic cross-origin error. + if (from.protocol === "https:" && next.protocol === "http:") { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Refusing HTTPS to HTTP auth redirect.", + ); + } + + if (next.origin !== from.origin) { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Refusing cross-origin auth redirect.", + ); + } + + if (next.protocol === "http:" && !isLoopbackHostname(next.hostname)) { + throw new TuiAuthError( + 0, + "UNSAFE_REDIRECT", + "Refusing non-loopback HTTP auth redirect.", + ); + } + + return next.toString(); +} + +function resolveApiUrl(apiBaseUrl: string, path: string): string { + const base = new URL(apiBaseUrl); + const prefix = base.pathname.replace(/\/?$/, "/"); + const resolved = new URL(path.replace(/^\/+/, ""), `https://resolve.invalid${prefix}`); + base.pathname = resolved.pathname; + base.search = resolved.search; + base.hash = ""; + return base.toString(); +} + +function parseUser(value: unknown): TuiUser { + const record = asRecord(value); + const id = asString(record?.id); + const email = asString(record?.email); + if (!id || !email) { + throw new TuiAuthError(500, "INVALID_USER", "Auth response missing user identity."); + } + const displayName = asString(record?.displayName); + return { + id, + email, + ...(displayName ? { displayName } : {}), + }; +} + +function parseWorkspace(value: unknown): TuiWorkspace { + const record = asRecord(value); + const id = asString(record?.id); + if (!id) { + throw new TuiAuthError(500, "INVALID_WORKSPACE", "Auth response missing workspace id."); + } + const name = asString(record?.name); + return { + id, + ...(name ? { name } : {}), + }; +} + +function errorFromBody( + status: number, + body: { error?: { code?: string; message?: string } }, +): TuiAuthError { + return new TuiAuthError( + status, + body.error?.code ?? "HTTP_ERROR", + body.error?.message ?? `Request failed with status ${status}`, + ); +} + +async function readJson( + response: Response, +): Promise<{ data?: unknown; error?: { code?: string; message?: string } }> { + try { + return await response.json() as { + data?: unknown; + error?: { code?: string; message?: string }; + }; + } catch (error) { + // Timeouts must surface; only swallow malformed JSON bodies. + if (isAbortError(error)) { + throw error; + } + return {}; + } +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? value as Record : undefined; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function isAbortError(error: unknown): boolean { + return Boolean( + error + && typeof error === "object" + && "name" in error + && (error as { name?: string }).name === "AbortError", + ); +} diff --git a/apps/tui/src/auth/authenticated-transport.test.ts b/apps/tui/src/auth/authenticated-transport.test.ts new file mode 100644 index 00000000..4c95bdb6 --- /dev/null +++ b/apps/tui/src/auth/authenticated-transport.test.ts @@ -0,0 +1,394 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { AuthenticatedTransport } from "./authenticated-transport.js"; +import { TuiCookieJar } from "./cookie-jar.js"; + +function jsonResponse( + status: number, + body: unknown, + headers?: Record, +): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + ...headers, + }, + }); +} + +describe("AuthenticatedTransport", () => { + it("attaches cookies to all requests and CSRF only to unsafe methods", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf-1" }); + const seen: Array<{ method: string; cookie?: string | null; csrf?: string | null }> = []; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => {}, + fetchImpl: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init); + seen.push({ + method: request.method, + cookie: request.headers.get("cookie"), + csrf: request.headers.get("x-csrf-token"), + }); + return jsonResponse(200, { ok: true }); + }, + }); + + await transport.fetch("http://127.0.0.1/api/v1/me"); + await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: "{}", + }); + + assert.deepEqual(seen, [ + { + method: "GET", + cookie: "df_session=sess; df_csrf=csrf-1", + csrf: null, + }, + { + method: "POST", + cookie: "df_session=sess; df_csrf=csrf-1", + csrf: "csrf-1", + }, + ]); + }); + + it("invokes onSessionInvalid once on 401 and does not retry the business request", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf-1" }); + let fetches = 0; + let invalidCalls = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + throw new Error("should not refresh on 401"); + }, + onSessionInvalid: async () => { + invalidCalls += 1; + }, + fetchImpl: async () => { + fetches += 1; + return jsonResponse(401, { error: { code: "UNAUTHORIZED" } }); + }, + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/me"); + assert.equal(response.status, 401); + assert.equal(fetches, 1); + assert.equal(invalidCalls, 1); + }); + + it("retries once only for CSRF_INVALID and uses response.clone for code checks", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "old" }); + let fetches = 0; + let refreshed = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + refreshed += 1; + jar.replace({ df_session: "sess", df_csrf: "new" }); + }, + onSessionInvalid: async () => { + throw new Error("should not invalidate after successful retry"); + }, + fetchImpl: async (_input, init) => { + fetches += 1; + const headers = new Headers(init?.headers); + if (fetches === 1) { + return jsonResponse(403, { error: { code: "CSRF_INVALID", message: "bad" } }); + } + assert.equal(headers.get("x-csrf-token"), "new"); + return jsonResponse(200, { success: true, data: { ok: true } }); + }, + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id: "x" }), + }); + const body = await response.json() as { success: boolean }; + assert.equal(response.status, 200); + assert.equal(body.success, true); + assert.equal(fetches, 2); + assert.equal(refreshed, 1); + }); + + it("does not retry other 403 responses", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let fetches = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + throw new Error("should not refresh"); + }, + onSessionInvalid: async () => {}, + fetchImpl: async () => { + fetches += 1; + return jsonResponse(403, { error: { code: "FORBIDDEN", message: "no" } }); + }, + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: "{}", + }); + assert.equal(response.status, 403); + assert.equal(fetches, 1); + }); + + it("invalidates session when Request body cannot be cloned for CSRF retry", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let invalid = 0; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + }); + const request = new Request("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: stream, + // @ts-expect-error Node fetch duplex + duplex: "half", + }); + // Consume clone ability by locking in environments that disallow second clone. + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + invalid += 1; + }, + fetchImpl: async () => jsonResponse(403, { error: { code: "CSRF_INVALID" } }), + }); + + // Force non-replayable by passing stream body via init. + const response = await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("{}")); + controller.close(); + }, + }), + // @ts-expect-error Node fetch duplex + duplex: "half", + }); + assert.equal(response.status, 403); + assert.equal(invalid, 1); + void request; + }); + + it("single-flights concurrent CSRF refresh", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "old" }); + let refreshCalls = 0; + let releaseRefresh!: () => void; + const refreshGate = new Promise((resolve) => { + releaseRefresh = resolve; + }); + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + refreshCalls += 1; + jar.replace({ df_session: "sess", df_csrf: "new" }); + await refreshGate; + }, + onSessionInvalid: async () => {}, + fetchImpl: async (input, init) => { + const method = String(init?.method ?? "GET"); + if (method === "POST" && jar.csrfToken() === "old") { + return jsonResponse(403, { error: { code: "CSRF_INVALID" } }); + } + return jsonResponse(200, { ok: true }); + }, + }); + + const pending = Promise.all([ + transport.fetch("http://127.0.0.1/api/v1/a", { method: "POST", body: "{}" }), + transport.fetch("http://127.0.0.1/api/v1/b", { method: "POST", body: "{}" }), + ]); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(refreshCalls, 1); + releaseRefresh(); + const responses = await pending; + assert.equal(refreshCalls, 1); + assert.deepEqual(responses.map((response) => response.status), [200, 200]); + }); + + it("invalidates session when CSRF refresh fails", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "old" }); + let invalid = 0; + let authRequired = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + throw new Error("refresh 401"); + }, + onSessionInvalid: async () => { + invalid += 1; + }, + fetchImpl: async () => jsonResponse(403, { error: { code: "CSRF_INVALID" } }), + }); + transport.onAuthRequired(() => { + authRequired += 1; + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: "{}", + }); + assert.equal(response.status, 403); + assert.equal(invalid, 1); + assert.equal(authRequired, 1); + }); + + it("notifies auth-required listeners once on 401", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let authRequired = 0; + let invalidCalls = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + invalidCalls += 1; + }, + fetchImpl: async () => jsonResponse(401, { error: { code: "UNAUTHORIZED" } }), + }); + transport.onAuthRequired(() => { + authRequired += 1; + }); + + await transport.fetch("http://127.0.0.1/api/v1/me"); + await transport.fetch("http://127.0.0.1/api/v1/me"); + assert.equal(authRequired, 1); + assert.equal(invalidCalls, 1); + }); + + it("dedups concurrent 401 cleanup before onSessionInvalid side effects", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let invalidCalls = 0; + let releaseCleanup!: () => void; + const cleanupGate = new Promise((resolve) => { + releaseCleanup = resolve; + }); + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + invalidCalls += 1; + await cleanupGate; + }, + fetchImpl: async () => jsonResponse(401, { error: { code: "UNAUTHORIZED" } }), + }); + + const pending = Promise.all([ + transport.fetch("http://127.0.0.1/api/v1/a"), + transport.fetch("http://127.0.0.1/api/v1/b"), + ]); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(invalidCalls, 1); + releaseCleanup(); + await pending; + assert.equal(invalidCalls, 1); + }); + + it("retries CSRF_INVALID with jar token when single-flight already advanced it", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "old" }); + let refreshCalls = 0; + let fetches = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => { + refreshCalls += 1; + jar.replace({ df_session: "sess", df_csrf: "from-refresh" }); + }, + onSessionInvalid: async () => { + throw new Error("should not invalidate"); + }, + fetchImpl: async (_input, init) => { + fetches += 1; + const headers = new Headers(init?.headers); + if (fetches === 1) { + // Simulate a peer request refreshing CSRF while this one is in flight. + jar.replace({ df_session: "sess", df_csrf: "peer-new" }); + return jsonResponse(403, { error: { code: "CSRF_INVALID" } }); + } + assert.equal(headers.get("x-csrf-token"), "peer-new"); + return jsonResponse(200, { ok: true }); + }, + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/datasources", { + method: "POST", + body: "{}", + }); + assert.equal(response.status, 200); + assert.equal(refreshCalls, 0); + assert.equal(fetches, 2); + }); + + it("replays sticky auth-required when listener subscribes after 401", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let invalidCalls = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + invalidCalls += 1; + }, + fetchImpl: async () => jsonResponse(401, { error: { code: "UNAUTHORIZED" } }), + }); + + await transport.fetch("http://127.0.0.1/api/v1/run-defaults"); + assert.equal(invalidCalls, 1); + + let lateListenerCalls = 0; + transport.onAuthRequired(() => { + lateListenerCalls += 1; + }); + assert.equal(lateListenerCalls, 1); + + let secondLateCalls = 0; + transport.onAuthRequired(() => { + secondLateCalls += 1; + }); + assert.equal(secondLateCalls, 1); + assert.equal(lateListenerCalls, 1); + }); + + it("keeps sensitive headers out of thrown error messages", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "super-secret", df_csrf: "csrf-secret" }); + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => {}, + fetchImpl: async () => { + throw new Error("network down"); + }, + }); + + await assert.rejects( + () => transport.fetch("http://127.0.0.1/api/v1/me"), + (error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + assert.equal(message.includes("super-secret"), false); + assert.equal(message.includes("csrf-secret"), false); + return true; + }, + ); + }); +}); diff --git a/apps/tui/src/auth/authenticated-transport.ts b/apps/tui/src/auth/authenticated-transport.ts new file mode 100644 index 00000000..61dd1066 --- /dev/null +++ b/apps/tui/src/auth/authenticated-transport.ts @@ -0,0 +1,202 @@ +import type { TuiCookieJar } from "./cookie-jar.js"; + +const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export type AuthenticatedTransportOptions = { + cookieJar: TuiCookieJar; + fetchImpl?: typeof fetch; + refreshCsrf: () => Promise; + onSessionInvalid: () => Promise; +}; + +export class AuthenticatedTransport { + private readonly cookieJar: TuiCookieJar; + private readonly fetchImpl: typeof fetch; + private readonly refreshCsrf: () => Promise; + private readonly onSessionInvalid: () => Promise; + private readonly authRequiredListeners = new Set<() => void>(); + private sessionInvalidNotified = false; + private sessionInvalidInflight: Promise | null = null; + private csrfRefreshInflight: Promise | null = null; + + constructor(options: AuthenticatedTransportOptions) { + this.cookieJar = options.cookieJar; + this.fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + this.refreshCsrf = options.refreshCsrf; + this.onSessionInvalid = options.onSessionInvalid; + } + + /** Subscribe to session-invalid signals (401 / failed CSRF recovery). */ + onAuthRequired(listener: () => void): () => void { + this.authRequiredListeners.add(listener); + // Sticky: preflight may 401 before Ink registers a listener — replay immediately. + if (this.sessionInvalidNotified) { + try { + listener(); + } catch { + // Listeners must not break transport cleanup. + } + } + return () => { + this.authRequiredListeners.delete(listener); + }; + } + + async fetch(input: string | URL | Request, init?: RequestInit): Promise { + const { first, replay } = createReplayPair(input, init); + const csrfUsed = this.cookieJar.csrfToken(); + const response = await this.send(first.input, first.init); + this.cookieJar.absorbSetCookie(response.headers); + + if (response.status === 401) { + await this.handleSessionInvalid(); + return response; + } + + if (response.status !== 403 || !(await isCsrfInvalid(response))) { + return response; + } + + if (!replay) { + await this.handleSessionInvalid(); + return response; + } + + const csrfNow = this.cookieJar.csrfToken(); + const jarAlreadyAdvanced = Boolean( + csrfUsed + && csrfNow + && csrfNow !== csrfUsed, + ); + + if (!jarAlreadyAdvanced) { + try { + await this.refreshCsrfSingleFlight(); + } catch { + // CSRF refresh 401/network failure: same recovery path as session invalid. + await this.handleSessionInvalid(); + return response; + } + } + + const retried = await this.send(replay.input, replay.init); + this.cookieJar.absorbSetCookie(retried.headers); + + if ( + retried.status === 401 + || (retried.status === 403 && (await isCsrfInvalid(retried))) + ) { + await this.handleSessionInvalid(); + } + return retried; + } + + private async handleSessionInvalid(): Promise { + // Dedup sticky flag BEFORE cleanup side effects so concurrent 401s share one path. + if (!this.sessionInvalidInflight) { + this.sessionInvalidNotified = true; + this.sessionInvalidInflight = this.runSessionInvalid(); + } + await this.sessionInvalidInflight; + } + + private async runSessionInvalid(): Promise { + await this.onSessionInvalid(); + for (const listener of this.authRequiredListeners) { + try { + listener(); + } catch { + // Listeners must not break transport cleanup. + } + } + } + + /** Share one in-flight CSRF refresh across concurrent 403 CSRF_INVALID callers. */ + private async refreshCsrfSingleFlight(): Promise { + if (!this.csrfRefreshInflight) { + this.csrfRefreshInflight = this.refreshCsrf().finally(() => { + this.csrfRefreshInflight = null; + }); + } + await this.csrfRefreshInflight; + } + + private async send( + input: string | URL | Request, + init?: RequestInit, + ): Promise { + if (input instanceof Request) { + const headers = new Headers(input.headers); + applyAuthHeaders(headers, input.method, this.cookieJar); + return this.fetchImpl(new Request(input, { headers })); + } + + const headers = new Headers(init?.headers); + const method = String(init?.method ?? "GET"); + applyAuthHeaders(headers, method, this.cookieJar); + return this.fetchImpl(input, { ...init, headers }); + } +} + +function applyAuthHeaders( + headers: Headers, + method: string, + cookieJar: TuiCookieJar, +): void { + const cookieHeader = cookieJar.headerValue(); + if (cookieHeader) { + headers.set("cookie", cookieHeader); + } + if (UNSAFE_METHODS.has(method.toUpperCase())) { + const csrf = cookieJar.csrfToken(); + if (csrf && !headers.has("x-csrf-token")) { + headers.set("x-csrf-token", csrf); + } + } +} + +function createReplayPair( + input: string | URL | Request, + init?: RequestInit, +): { + first: { input: string | URL | Request; init?: RequestInit }; + replay?: { input: string | URL | Request; init?: RequestInit }; +} { + if (input instanceof Request) { + try { + const replayRequest = input.clone(); + return { + first: { input }, + replay: { input: replayRequest }, + }; + } catch { + return { first: { input } }; + } + } + + if (init?.body && isNonReplayableBody(init.body)) { + return { first: { input, ...(init ? { init } : {}) } }; + } + + const replayInit = init ? { ...init } : undefined; + return { + first: { input, ...(init ? { init } : {}) }, + replay: { input, ...(replayInit ? { init: replayInit } : {}) }, + }; +} + +function isNonReplayableBody(body: BodyInit): boolean { + return typeof ReadableStream !== "undefined" && body instanceof ReadableStream; +} + +async function isCsrfInvalid(response: Response): Promise { + try { + const body = await response.clone().json() as { + error?: { code?: string }; + code?: string; + }; + return body?.error?.code === "CSRF_INVALID" || body?.code === "CSRF_INVALID"; + } catch { + return false; + } +} diff --git a/apps/tui/src/auth/bootstrap.test.ts b/apps/tui/src/auth/bootstrap.test.ts new file mode 100644 index 00000000..2e56aa85 --- /dev/null +++ b/apps/tui/src/auth/bootstrap.test.ts @@ -0,0 +1,296 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + bootstrapTuiAuth, + completeInteractiveLogin, + createAuthController, + SESSION_EXPIRY_TOLERANCE_MS, +} from "./bootstrap.js"; +import { TuiAuthClient } from "./auth-client.js"; +import { TuiCookieJar } from "./cookie-jar.js"; +import { TuiSessionStore } from "./session-store.js"; +import type { StoredTuiSession } from "./types.js"; + +function statusOk(): Response { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: true }, + }); +} + +describe("bootstrapTuiAuth", () => { + it("restores cached session via GET /api/v1/me", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-bootstrap-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const session = sampleSession("http://127.0.0.1:8787"); + await store.save(session); + const paths: string[] = []; + + const result = await bootstrapTuiAuth({ + apiBaseUrl: "http://127.0.0.1:8787", + sessionStore: store, + fetchImpl: async (input) => { + const path = new URL(String(input)).pathname; + paths.push(path); + if (path === "/api/v1/auth/status") return statusOk(); + if (path === "/api/v1/me") { + return json(200, { + success: true, + data: { + user: { id: "u1", email: "a@example.com", displayName: "A" }, + workspace: { id: "w1", name: "Personal" }, + }, + }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + }); + + assert.equal(result.kind, "authenticated"); + assert.deepEqual(paths, ["/api/v1/auth/status", "/api/v1/me"]); + }); + + it("skips /me only when expiresAt is beyond tolerance", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-bootstrap-exp-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const now = Date.UTC(2026, 0, 1, 12, 0, 0); + await store.save({ + ...sampleSession("http://127.0.0.1:8787"), + expiresAt: new Date(now - SESSION_EXPIRY_TOLERANCE_MS - 1).toISOString(), + }); + const paths: string[] = []; + + const result = await bootstrapTuiAuth({ + apiBaseUrl: "http://127.0.0.1:8787", + sessionStore: store, + now: () => now, + fetchImpl: async (input) => { + paths.push(new URL(String(input)).pathname); + return statusOk(); + }, + }); + + assert.equal(result.kind, "login-required"); + assert.deepEqual(paths, ["/api/v1/auth/status"]); + }); + + it("still calls /me when expiresAt is within tolerance", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-bootstrap-tol-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const now = Date.UTC(2026, 0, 1, 12, 0, 0); + await store.save({ + ...sampleSession("http://127.0.0.1:8787"), + expiresAt: new Date(now - SESSION_EXPIRY_TOLERANCE_MS + 1_000).toISOString(), + }); + const paths: string[] = []; + + await bootstrapTuiAuth({ + apiBaseUrl: "http://127.0.0.1:8787", + sessionStore: store, + now: () => now, + fetchImpl: async (input) => { + const path = new URL(String(input)).pathname; + paths.push(path); + if (path === "/api/v1/auth/status") return statusOk(); + return json(401, { success: false, error: { code: "UNAUTHORIZED", message: "gone" } }); + }, + }); + + assert.ok(paths.includes("/api/v1/me")); + }); + + it("ignores cache restore when --no-auto-login is set but keeps previousSession", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-bootstrap-noauto-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const cached = sampleSession("http://127.0.0.1:8787"); + await store.save(cached); + const paths: string[] = []; + + const result = await bootstrapTuiAuth({ + apiBaseUrl: "http://127.0.0.1:8787", + noAutoLogin: true, + sessionStore: store, + fetchImpl: async (input) => { + paths.push(new URL(String(input)).pathname); + return statusOk(); + }, + }); + + assert.equal(result.kind, "login-required"); + assert.deepEqual(paths, ["/api/v1/auth/status"]); + if (result.kind === "login-required") { + assert.equal(result.previousSession?.user.email, cached.user.email); + assert.equal(result.previousSession?.cookies.df_session, "sess"); + } + }); +}); + +describe("completeInteractiveLogin", () => { + it("does not overwrite cache when login fails", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-login-fail-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const previous = sampleSession("http://127.0.0.1:8787", "old@example.com"); + await store.save(previous); + + await assert.rejects(() => + completeInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + email: "new@example.com", + password: "bad", + fetchImpl: async () => + json(401, { + success: false, + error: { code: "UNAUTHORIZED", message: "Invalid email or password." }, + }), + sessionStore: store, + previousSession: previous, + })); + + const still = await store.load("http://127.0.0.1:8787"); + assert.equal(still?.user.email, "old@example.com"); + }); + + it("best-effort logs out previous session then saves the new one", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-login-ok-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const previous = sampleSession("http://127.0.0.1:8787", "old@example.com"); + previous.cookies = { df_session: "old-sess", df_csrf: "old-csrf" }; + await store.save(previous); + const paths: string[] = []; + + const result = await completeInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + email: "new@example.com", + password: "good-password", + sessionStore: store, + previousSession: previous, + fetchImpl: async (input, init) => { + const path = new URL(String(input)).pathname; + paths.push(`${String(init?.method ?? "GET")} ${path}`); + if (path === "/api/v1/auth/login") { + return json( + 200, + { + success: true, + data: { + user: { id: "u2", email: "new@example.com" }, + workspace: { id: "w2" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=new-sess; Path=/", "df_csrf=new-csrf; Path=/"], + ); + } + if (path === "/api/v1/auth/logout") { + return json(200, { success: true, data: { ok: true } }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + }); + + assert.equal(result.session.user.email, "new@example.com"); + assert.ok(paths.includes("POST /api/v1/auth/logout")); + const saved = await store.load("http://127.0.0.1:8787"); + assert.equal(saved?.user.email, "new@example.com"); + }); + + it("keeps new account and warns when old logout fails", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-login-warn-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + const previous = sampleSession("http://127.0.0.1:8787", "old@example.com"); + previous.cookies = { df_session: "old-sess", df_csrf: "old-csrf" }; + + const result = await completeInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + email: "new@example.com", + password: "good-password", + sessionStore: store, + previousSession: previous, + fetchImpl: async (input) => { + const path = new URL(String(input)).pathname; + if (path === "/api/v1/auth/login") { + return json( + 200, + { + success: true, + data: { + user: { id: "u2", email: "new@example.com" }, + workspace: { id: "w2" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=new-sess; Path=/", "df_csrf=new-csrf; Path=/"], + ); + } + return json(503, { success: false, error: { code: "UNAVAILABLE", message: "down" } }); + }, + }); + + assert.equal(result.session.user.email, "new@example.com"); + assert.match(result.warning ?? "", /previous remote session/i); + }); +}); + +describe("createAuthController", () => { + it("distinguishes remote logout failure from local cleanup failure", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-logout-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + await store.save(sampleSession("http://127.0.0.1:8787")); + + const remoteFailed = createAuthController({ + apiBaseUrl: "http://127.0.0.1:8787", + sessionStore: store, + authClient: new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: new TuiCookieJar(), + fetchImpl: async () => json(503, { success: false, error: { code: "DOWN", message: "x" } }), + }), + }); + const remote = await remoteFailed.logout(); + assert.equal(remote.kind, "remote-failed"); + + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + const localFailed = createAuthController({ + apiBaseUrl: "http://127.0.0.1:8787", + sessionStore: { + path: store.path, + async remove() { + throw new Error("disk full"); + }, + } as unknown as TuiSessionStore, + authClient: new TuiAuthClient({ + apiBaseUrl: "http://127.0.0.1:8787", + cookieJar: jar, + fetchImpl: async () => json(200, { success: true, data: { ok: true } }), + }), + }); + const local = await localFailed.logout(); + assert.equal(local.kind, "local-cleanup-failed"); + if (local.kind === "local-cleanup-failed") { + assert.equal(local.storePath, store.path); + assert.match(local.error, /disk full/i); + } + }); +}); + +function sampleSession(apiBaseUrl: string, email = "a@example.com"): StoredTuiSession { + return { + apiBaseUrl, + cookies: { df_session: "sess", df_csrf: "csrf" }, + user: { id: "u1", email }, + workspace: { id: "w1" }, + expiresAt: "2099-01-01T00:00:00.000Z", + }; +} + +function json(status: number, body: unknown, setCookies: string[] = []): Response { + const headers = new Headers({ "content-type": "application/json" }); + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/apps/tui/src/auth/bootstrap.ts b/apps/tui/src/auth/bootstrap.ts new file mode 100644 index 00000000..b002f31e --- /dev/null +++ b/apps/tui/src/auth/bootstrap.ts @@ -0,0 +1,255 @@ +import { AuthenticatedTransport } from "./authenticated-transport.js"; +import { TuiAuthClient, TuiAuthError } from "./auth-client.js"; +import { TuiCookieJar } from "./cookie-jar.js"; +import { normalizeApiBaseUrl, TuiSessionStore } from "./session-store.js"; +import type { AuthStatus, StoredTuiSession } from "./types.js"; + +/** Local expiresAt may lag clock skew; only skip /me beyond this grace. */ +export const SESSION_EXPIRY_TOLERANCE_MS = 60_000; + +export type BootstrapAuthOptions = { + apiBaseUrl: string; + noAutoLogin?: boolean; + sessionStore?: TuiSessionStore; + fetchImpl?: typeof fetch; + now?: () => number; +}; + +export type BootstrapAuthResult = + | { + kind: "authenticated"; + session: StoredTuiSession; + transport: AuthenticatedTransport; + authClient: TuiAuthClient; + cookieJar: TuiCookieJar; + sessionStore: TuiSessionStore; + warning?: string; + } + | { + kind: "login-required"; + status: AuthStatus; + previousSession?: StoredTuiSession; + authClient: TuiAuthClient; + cookieJar: TuiCookieJar; + sessionStore: TuiSessionStore; + }; + +export async function bootstrapTuiAuth( + options: BootstrapAuthOptions, +): Promise { + const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl); + const sessionStore = options.sessionStore ?? new TuiSessionStore(); + const cookieJar = new TuiCookieJar(); + const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + const authClient = new TuiAuthClient({ apiBaseUrl, cookieJar, fetchImpl }); + const now = options.now ?? Date.now; + const status = await authClient.getStatus(); + const cached = await sessionStore.load(apiBaseUrl); + + // --no-auto-login: do not restore cookies / call /me, but keep the cached + // session as previousSession so a successful re-login can revoke it remotely. + if (options.noAutoLogin) { + return { + kind: "login-required", + status, + ...(cached ? { previousSession: cached } : {}), + authClient, + cookieJar, + sessionStore, + }; + } + + if (!cached) { + return { + kind: "login-required", + status, + authClient, + cookieJar, + sessionStore, + }; + } + + if (isExpiredBeyondTolerance(cached.expiresAt, now())) { + await sessionStore.remove(apiBaseUrl); + return { + kind: "login-required", + status, + previousSession: cached, + authClient, + cookieJar, + sessionStore, + }; + } + + cookieJar.replace(cached.cookies); + try { + const me = await authClient.me(); + const session: StoredTuiSession = { + ...cached, + user: { + id: me.id, + email: me.email, + ...(me.displayName ? { displayName: me.displayName } : {}), + }, + workspace: me.workspace, + cookies: cookieJar.snapshot(), + }; + await sessionStore.save(session); + return { + kind: "authenticated", + session, + transport: createTransport({ cookieJar, authClient, sessionStore, apiBaseUrl, fetchImpl }), + authClient, + cookieJar, + sessionStore, + }; + } catch (error) { + if (error instanceof TuiAuthError && (error.status === 401 || error.code === "UNAUTHORIZED")) { + await sessionStore.remove(apiBaseUrl); + cookieJar.clear(); + return { + kind: "login-required", + status, + previousSession: cached, + authClient, + cookieJar, + sessionStore, + }; + } + throw error; + } +} + +export async function completeInteractiveLogin(options: { + apiBaseUrl: string; + email: string; + password: string; + fetchImpl: typeof fetch; + sessionStore: TuiSessionStore; + previousSession?: StoredTuiSession; +}): Promise<{ + session: StoredTuiSession; + transport: AuthenticatedTransport; + authClient: TuiAuthClient; + cookieJar: TuiCookieJar; + warning?: string; +}> { + const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl); + const loginJar = new TuiCookieJar(); + const loginClient = new TuiAuthClient({ + apiBaseUrl, + cookieJar: loginJar, + fetchImpl: options.fetchImpl, + }); + + // Login uses a fresh jar so failures never mutate the previous in-memory session. + const session = await loginClient.login(options.email, options.password); + + let warning: string | undefined; + if (options.previousSession?.cookies && Object.keys(options.previousSession.cookies).length > 0) { + try { + const oldJar = new TuiCookieJar(); + oldJar.replace(options.previousSession.cookies); + const oldClient = new TuiAuthClient({ + apiBaseUrl, + cookieJar: oldJar, + fetchImpl: options.fetchImpl, + }); + await oldClient.logout(); + } catch { + warning = + "Signed in with the new account, but the previous remote session could not be revoked."; + } + } + + await options.sessionStore.save(session); + const cookieJar = loginJar; + const authClient = loginClient; + return { + session, + authClient, + cookieJar, + transport: createTransport({ + cookieJar, + authClient, + sessionStore: options.sessionStore, + apiBaseUrl, + fetchImpl: options.fetchImpl, + }), + ...(warning ? { warning } : {}), + }; +} + +export function createTransport(options: { + cookieJar: TuiCookieJar; + authClient: TuiAuthClient; + sessionStore: TuiSessionStore; + apiBaseUrl: string; + fetchImpl: typeof fetch; +}): AuthenticatedTransport { + const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl); + return new AuthenticatedTransport({ + cookieJar: options.cookieJar, + fetchImpl: options.fetchImpl, + refreshCsrf: () => options.authClient.refreshCsrf(), + onSessionInvalid: async () => { + options.cookieJar.clear(); + await options.sessionStore.remove(apiBaseUrl); + }, + }); +} + +/** Wire Ink exit when the shared transport observes an invalidated session. */ +export function bindTransportAuthRequired( + transport: AuthenticatedTransport, + onAuthRequired: () => void, +): () => void { + return transport.onAuthRequired(onAuthRequired); +} + +export function isExpiredBeyondTolerance(expiresAt: string, nowMs: number): boolean { + const expiresMs = Date.parse(expiresAt); + if (Number.isNaN(expiresMs)) { + return true; + } + return nowMs > expiresMs + SESSION_EXPIRY_TOLERANCE_MS; +} + +export function createAuthController(options: { + apiBaseUrl: string; + authClient: TuiAuthClient; + sessionStore: TuiSessionStore; +}): import("./types.js").AuthCommandController { + const apiBaseUrl = normalizeApiBaseUrl(options.apiBaseUrl); + return { + async logout() { + try { + await options.authClient.logout(); + } catch { + return { + kind: "remote-failed" as const, + clearLocalOnly: async () => { + options.authClient.cookieJar.clear(); + await options.sessionStore.remove(apiBaseUrl); + }, + }; + } + + try { + await options.sessionStore.remove(apiBaseUrl); + return { kind: "complete" as const }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + kind: "local-cleanup-failed" as const, + storePath: options.sessionStore.path, + error: message, + retry: async () => { + options.authClient.cookieJar.clear(); + await options.sessionStore.remove(apiBaseUrl); + }, + }; + } + }, + }; +} diff --git a/apps/tui/src/auth/browser-opener.test.ts b/apps/tui/src/auth/browser-opener.test.ts new file mode 100644 index 00000000..aab7c1d4 --- /dev/null +++ b/apps/tui/src/auth/browser-opener.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { describe, it } from "node:test"; +import { buildRegisterUrl, openBrowserUrl } from "./browser-opener.js"; + +describe("buildRegisterUrl", () => { + it("keeps deployment path prefix from publicBaseUrl", () => { + assert.equal( + buildRegisterUrl("https://example.com/deploy"), + "https://example.com/deploy/register", + ); + assert.equal( + buildRegisterUrl("https://example.com/deploy/"), + "https://example.com/deploy/register", + ); + }); +}); + +describe("openBrowserUrl", () => { + it("uses rundll32 argument array on Windows without shell", () => { + const calls: Array<{ command: string; args: string[]; options: object }> = []; + const result = openBrowserUrl("http://127.0.0.1:3000/register", { + platform: "win32", + spawn: ((command: string, args: string[], options: object) => { + calls.push({ command, args, options }); + return fakeChild(); + }) as typeof import("node:child_process").spawn, + }); + assert.equal(result.ok, true); + assert.deepEqual(calls[0]?.command, "rundll32"); + assert.deepEqual(calls[0]?.args, [ + "url.dll,FileProtocolHandler", + "http://127.0.0.1:3000/register", + ]); + assert.equal((calls[0]?.options as { shell?: boolean }).shell, false); + }); + + it("uses open on macOS and xdg-open on Linux", () => { + const mac = captureSpawn("darwin", "https://example.com/register"); + assert.deepEqual(mac, { command: "open", args: ["https://example.com/register"] }); + const linux = captureSpawn("linux", "https://example.com/register"); + assert.deepEqual(linux, { command: "xdg-open", args: ["https://example.com/register"] }); + }); + + it("returns the full URL when spawn fails", () => { + const result = openBrowserUrl("https://example.com/app/register", { + platform: "linux", + spawn: (() => { + throw new Error("spawn failed"); + }) as typeof import("node:child_process").spawn, + }); + assert.equal(result.ok, false); + if (!result.ok) { + assert.equal(result.url, "https://example.com/app/register"); + } + }); + + it("attaches an error listener so async spawn failures do not crash", () => { + let errorListener: ((err: Error) => void) | undefined; + const result = openBrowserUrl("https://example.com/register", { + platform: "linux", + spawn: ((() => { + const child = fakeChild(); + const originalOn = child.on.bind(child); + child.on = ((event: string, listener: (...args: unknown[]) => void) => { + if (event === "error") { + errorListener = listener as (err: Error) => void; + } + return originalOn(event, listener); + }) as typeof child.on; + return child; + }) as unknown) as typeof import("node:child_process").spawn, + }); + assert.equal(result.ok, true); + if (result.ok) { + assert.equal(result.url, "https://example.com/register"); + } + assert.equal(typeof errorListener, "function"); + assert.doesNotThrow(() => errorListener?.(new Error("ENOENT"))); + }); +}); + +function captureSpawn(osPlatform: NodeJS.Platform, url: string) { + let captured: { command: string; args: string[] } | undefined; + openBrowserUrl(url, { + platform: osPlatform, + spawn: ((command: string, args: string[]) => { + captured = { command, args }; + return fakeChild(); + }) as typeof import("node:child_process").spawn, + }); + return captured; +} + +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + unref: () => void; + stdin: null; + stdout: null; + stderr: null; + pid: number; + }; + child.unref = () => {}; + child.stdin = null; + child.stdout = null; + child.stderr = null; + child.pid = 1; + return child; +} diff --git a/apps/tui/src/auth/browser-opener.ts b/apps/tui/src/auth/browser-opener.ts new file mode 100644 index 00000000..1440d435 --- /dev/null +++ b/apps/tui/src/auth/browser-opener.ts @@ -0,0 +1,76 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { platform } from "node:os"; + +export type BrowserOpenerDeps = { + platform?: NodeJS.Platform; + spawn?: typeof spawn; +}; + +export type OpenBrowserResult = + | { ok: true; command: string; args: string[]; url: string } + | { ok: false; reason: string; url: string }; + +export function buildRegisterUrl(publicBaseUrl: string): string { + const base = new URL(publicBaseUrl); + if (base.protocol !== "http:" && base.protocol !== "https:") { + throw new Error("Registration URL must use http or https."); + } + base.pathname = `${base.pathname.replace(/\/?$/, "/")}register`; + base.search = ""; + base.hash = ""; + return base.toString(); +} + +export function openBrowserUrl( + url: string, + deps: BrowserOpenerDeps = {}, +): OpenBrowserResult { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { ok: false, reason: "Invalid URL", url }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false, reason: "URL must use http or https", url }; + } + + const osPlatform = deps.platform ?? platform(); + const spawnImpl = deps.spawn ?? spawn; + const { command, args } = browserCommand(osPlatform, parsed.toString()); + const normalizedUrl = parsed.toString(); + + try { + const child: ChildProcess = spawnImpl(command, args, { + shell: false, + detached: true, + stdio: "ignore", + }); + // Missing binaries (e.g. xdg-open) emit 'error' asynchronously; without a + // listener Node can crash the process even though spawn() returned. + child.on("error", () => { + // Caller always prints a copyable URL; swallow to avoid unhandled crash. + }); + child.unref(); + return { ok: true, command, args, url: normalizedUrl }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + return { ok: false, reason, url: normalizedUrl }; + } +} + +function browserCommand( + osPlatform: NodeJS.Platform, + url: string, +): { command: string; args: string[] } { + if (osPlatform === "win32") { + return { + command: "rundll32", + args: ["url.dll,FileProtocolHandler", url], + }; + } + if (osPlatform === "darwin") { + return { command: "open", args: [url] }; + } + return { command: "xdg-open", args: [url] }; +} diff --git a/apps/tui/src/auth/cookie-jar.test.ts b/apps/tui/src/auth/cookie-jar.test.ts new file mode 100644 index 00000000..71aad670 --- /dev/null +++ b/apps/tui/src/auth/cookie-jar.test.ts @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { TuiCookieJar } from "./cookie-jar.js"; + +describe("TuiCookieJar", () => { + it("parses multiple Set-Cookie headers and keeps only name/value", () => { + const jar = new TuiCookieJar(); + const headers = new Headers(); + headers.append( + "set-cookie", + "df_session=abc%20123; Path=/; HttpOnly; Max-Age=3600", + ); + headers.append("set-cookie", "df_csrf=token-value; Path=/; Max-Age=3600"); + jar.absorbSetCookie(headers); + + assert.deepEqual(jar.snapshot(), { + df_session: "abc 123", + df_csrf: "token-value", + }); + assert.equal(jar.csrfToken(), "token-value"); + assert.equal( + jar.headerValue(), + "df_session=abc%20123; df_csrf=token-value", + ); + }); + + it("generates a stable Cookie header from snapshot order", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "s1", df_csrf: "c1" }); + assert.equal(jar.headerValue(), "df_session=s1; df_csrf=c1"); + }); + + it("replace clears previous cookies so sessions do not mix", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "old", df_csrf: "old-csrf", leftover: "x" }); + jar.replace({ df_session: "new", df_csrf: "new-csrf" }); + assert.deepEqual(jar.snapshot(), { + df_session: "new", + df_csrf: "new-csrf", + }); + assert.equal(jar.csrfToken(), "new-csrf"); + }); + + it("does not expose toJSON to avoid accidental secret logging", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "secret", df_csrf: "csrf" }); + assert.equal( + Object.prototype.hasOwnProperty.call(jar, "toJSON"), + false, + ); + assert.equal(typeof (jar as { toJSON?: unknown }).toJSON, "undefined"); + }); + + it("clear removes all cookies", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "s", df_csrf: "c" }); + jar.clear(); + assert.deepEqual(jar.snapshot(), {}); + assert.equal(jar.headerValue(), undefined); + assert.equal(jar.csrfToken(), undefined); + }); +}); diff --git a/apps/tui/src/auth/cookie-jar.ts b/apps/tui/src/auth/cookie-jar.ts new file mode 100644 index 00000000..5ae83dba --- /dev/null +++ b/apps/tui/src/auth/cookie-jar.ts @@ -0,0 +1,69 @@ +const CSRF_COOKIE = "df_csrf"; + +export class TuiCookieJar { + private readonly store: Record = Object.create(null); + + replace(cookies: Record): void { + this.clear(); + for (const [name, value] of Object.entries(cookies ?? {})) { + if (!name) { + continue; + } + this.store[name] = String(value); + } + } + + absorbSetCookie(headers: Headers): void { + const values = + typeof headers.getSetCookie === "function" + ? headers.getSetCookie() + : splitSetCookieHeader(headers.get("set-cookie")); + + for (const cookie of values) { + const pair = String(cookie).split(";", 1)[0] ?? ""; + const eq = pair.indexOf("="); + if (eq <= 0) { + continue; + } + const name = pair.slice(0, eq).trim(); + if (!name) { + continue; + } + const rawValue = pair.slice(eq + 1); + try { + this.store[name] = decodeURIComponent(rawValue); + } catch { + this.store[name] = rawValue; + } + } + } + + headerValue(): string | undefined { + const parts = Object.entries(this.store).map( + ([name, value]) => `${name}=${encodeURIComponent(value)}`, + ); + return parts.length > 0 ? parts.join("; ") : undefined; + } + + csrfToken(): string | undefined { + const value = this.store[CSRF_COOKIE]; + return typeof value === "string" && value.length > 0 ? value : undefined; + } + + snapshot(): Record { + return { ...this.store }; + } + + clear(): void { + for (const key of Object.keys(this.store)) { + delete this.store[key]; + } + } +} + +function splitSetCookieHeader(value: string | null): string[] { + if (!value) { + return []; + } + return [value]; +} diff --git a/apps/tui/src/auth/index.ts b/apps/tui/src/auth/index.ts new file mode 100644 index 00000000..4e286854 --- /dev/null +++ b/apps/tui/src/auth/index.ts @@ -0,0 +1,33 @@ +export { AuthenticatedTransport } from "./authenticated-transport.js"; +export { DEFAULT_AUTH_TIMEOUT_MS, TuiAuthClient, TuiAuthError } from "./auth-client.js"; +export { + bindTransportAuthRequired, + bootstrapTuiAuth, + completeInteractiveLogin, + createAuthController, + createTransport, + isExpiredBeyondTolerance, + SESSION_EXPIRY_TOLERANCE_MS, +} from "./bootstrap.js"; +export { buildRegisterUrl, openBrowserUrl } from "./browser-opener.js"; +export { TuiCookieJar } from "./cookie-jar.js"; +export { + canHidePasswordEcho, + createSecurePrompt, + isUnreachableAuthError, + resolveReadlineTerminal, + runInteractiveLogin, +} from "./interactive-login.js"; +export { + normalizeApiBaseUrl, + resolveTuiAuthStorePath, + TuiSessionStore, +} from "./session-store.js"; +export type { + AppExitReason, + AuthCommandController, + AuthStatus, + StoredTuiSession, + TuiUser, + TuiWorkspace, +} from "./types.js"; diff --git a/apps/tui/src/auth/interactive-login.test.ts b/apps/tui/src/auth/interactive-login.test.ts new file mode 100644 index 00000000..c85e3485 --- /dev/null +++ b/apps/tui/src/auth/interactive-login.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + canHidePasswordEcho, + createSecurePrompt, + resolveReadlineTerminal, + runInteractiveLogin, + type PromptFn, +} from "./interactive-login.js"; +import { TuiAuthError } from "./auth-client.js"; +import { TuiSessionStore } from "./session-store.js"; +import { PassThrough } from "node:stream"; + +describe("runInteractiveLogin", () => { + it("supports login, registration guidance, and exit without echoing passwords", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-interactive-")); + const store = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + const output: string[] = []; + const passwords: string[] = []; + const answers = ["2", "", "1", "user@example.com", "secret-password", "3"]; + let answerIndex = 0; + const prompt: PromptFn = { + question: async () => answers[answerIndex++] ?? "3", + password: async () => { + const value = answers[answerIndex++] ?? ""; + passwords.push(value); + return value; + }, + close: () => {}, + }; + + let openedUrl: string | undefined; + const first = await runInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + status: { + publicBaseUrl: "http://127.0.0.1:3000/app", + registrationEnabled: true, + }, + deps: { + prompt, + stdout: { + write(chunk: string) { + output.push(String(chunk)); + return true; + }, + } as NodeJS.WritableStream, + fetchImpl: async (input) => { + const path = new URL(String(input)).pathname; + if (path === "/api/v1/auth/login") { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "user@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=s; Path=/", "df_csrf=c; Path=/"], + ); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + sessionStore: store, + openBrowser: (url) => { + openedUrl = url; + return { ok: true, command: "xdg-open", args: [url], url }; + }, + }, + }); + + assert.equal(first.kind, "authenticated"); + assert.equal(openedUrl, "http://127.0.0.1:3000/app/register"); + assert.equal(passwords[0], "secret-password"); + assert.equal(output.join("").includes("secret-password"), false); + + const exitResult = await runInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + status: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + deps: { + prompt: { + question: async () => "3", + password: async () => "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + fetchImpl: async () => json(500, {}), + sessionStore: store, + }, + }); + assert.equal(exitResult.kind, "exit"); + }); + + it("does not auto-loop login after rate limiting", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-rate-")); + const store = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + let loginCalls = 0; + const answers = ["1", "user@example.com", "pw", "3"]; + let idx = 0; + const result = await runInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + status: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + deps: { + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + sessionStore: store, + fetchImpl: async () => { + loginCalls += 1; + return json(429, { + success: false, + error: { code: "RATE_LIMITED", message: "Too many requests" }, + }); + }, + }, + }); + assert.equal(result.kind, "exit"); + assert.equal(loginCalls, 1); + }); + + it("createSecurePrompt rejects overlapping readers on the same stdin", async () => { + const stdin = new PassThrough(); + const stdoutChunks: string[] = []; + const stdout = new PassThrough(); + stdout.on("data", (chunk) => stdoutChunks.push(String(chunk))); + const prompt = createSecurePrompt({ stdin, stdout }); + + const first = prompt.question("Q1: "); + await assert.rejects(() => prompt.question("Q2: "), /already waiting/i); + stdin.write("one\n"); + assert.equal(await first, "one"); + await prompt.close(); + }); + + it("uses stdin TTY (not stdout) for terminal mode under piped stdout", () => { + const stdin = Object.assign(new PassThrough(), { + isTTY: true, + isRaw: false, + setRawMode(mode: boolean) { + this.isRaw = mode; + }, + }); + const stdout = Object.assign(new PassThrough(), { isTTY: false }); + assert.equal(resolveReadlineTerminal(stdin), true); + assert.equal(Boolean((stdout as { isTTY?: boolean }).isTTY), false); + assert.equal(canHidePasswordEcho(stdin), true); + }); + + it("refuses password when stdin cannot hide echo", async () => { + const stdin = new PassThrough(); // not a TTY + const stdout = Object.assign(new PassThrough(), { isTTY: true }); + const prompt = createSecurePrompt({ stdin, stdout }); + await assert.rejects(() => prompt.password("Password: "), /hide echo/i); + await prompt.close(); + }); + + it("reads password with stdin TTY + piped stdout without echoing to stdout", async () => { + const modes: boolean[] = []; + const stdin = Object.assign(new PassThrough(), { + isTTY: true, + isRaw: false, + setRawMode(mode: boolean) { + this.isRaw = mode; + modes.push(mode); + }, + }); + const stdoutChunks: string[] = []; + const stdout = Object.assign(new PassThrough(), { isTTY: false }); + stdout.on("data", (chunk) => stdoutChunks.push(String(chunk))); + const prompt = createSecurePrompt({ stdin, stdout }); + + const pending = prompt.password("Password: "); + // Allow readline to attach before writing. + await new Promise((resolve) => setImmediate(resolve)); + stdin.write("s3cret\n"); + assert.equal(await pending, "s3cret"); + assert.equal(stdoutChunks.join("").includes("s3cret"), false); + assert.ok(modes.includes(true)); + assert.equal(stdin.isRaw, false); + await prompt.close(); + }); + + it("returns unreachable on login network/timeout errors", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-unreachable-")); + const store = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + const answers = ["1", "user@example.com", "pw"]; + let idx = 0; + const result = await runInteractiveLogin({ + apiBaseUrl: "http://127.0.0.1:8787", + status: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + deps: { + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + sessionStore: store, + fetchImpl: async () => { + throw new TuiAuthError(0, "TIMEOUT", "Auth request timed out after 15ms"); + }, + }, + }); + assert.equal(result.kind, "unreachable"); + }); +}); + +function json(status: number, body: unknown, setCookies: string[] = []): Response { + const headers = new Headers({ "content-type": "application/json" }); + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/apps/tui/src/auth/interactive-login.ts b/apps/tui/src/auth/interactive-login.ts new file mode 100644 index 00000000..77c1012a --- /dev/null +++ b/apps/tui/src/auth/interactive-login.ts @@ -0,0 +1,251 @@ +import { createInterface, type Interface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import { completeInteractiveLogin } from "./bootstrap.js"; +import { buildRegisterUrl, openBrowserUrl, type BrowserOpenerDeps } from "./browser-opener.js"; +import { TuiAuthError } from "./auth-client.js"; +import type { TuiSessionStore } from "./session-store.js"; +import type { AuthStatus, StoredTuiSession } from "./types.js"; +import type { AuthenticatedTransport } from "./authenticated-transport.js"; +import type { TuiAuthClient } from "./auth-client.js"; +import type { TuiCookieJar } from "./cookie-jar.js"; + +export type PromptFn = { + question(message: string): Promise; + password(message: string): Promise; + close(): Promise | void; +}; + +export type InteractiveLoginDeps = { + prompt?: PromptFn; + stdout?: NodeJS.WritableStream; + stdin?: NodeJS.ReadableStream; + fetchImpl: typeof fetch; + sessionStore: TuiSessionStore; + browser?: BrowserOpenerDeps; + openBrowser?: typeof openBrowserUrl; +}; + +export type InteractiveLoginSuccess = { + kind: "authenticated"; + session: StoredTuiSession; + transport: AuthenticatedTransport; + authClient: TuiAuthClient; + cookieJar: TuiCookieJar; + warning?: string; +}; + +export type InteractiveLoginResult = + | InteractiveLoginSuccess + | { kind: "exit" } + | { kind: "unreachable"; error: unknown }; + +export async function runInteractiveLogin(options: { + apiBaseUrl: string; + status: AuthStatus; + previousSession?: StoredTuiSession; + deps: InteractiveLoginDeps; +}): Promise { + const stdout = options.deps.stdout ?? process.stdout; + const prompt = options.deps.prompt ?? createSecurePrompt({ + stdin: options.deps.stdin ?? process.stdin, + stdout, + }); + const open = options.deps.openBrowser ?? openBrowserUrl; + + try { + while (true) { + writeLine(stdout, ""); + writeLine(stdout, "DataFoundry TUI login"); + writeLine(stdout, " [1] Sign in with email and password"); + if (options.status.registrationEnabled) { + writeLine(stdout, " [2] Open web registration"); + } + writeLine(stdout, " [3] Exit"); + const choice = (await prompt.question("Select an option: ")).trim(); + + if (choice === "3" || choice.toLowerCase() === "q" || choice.toLowerCase() === "exit") { + return { kind: "exit" }; + } + + if (choice === "2" && options.status.registrationEnabled) { + const registerUrl = buildRegisterUrl(options.status.publicBaseUrl); + const opened = open(registerUrl, options.deps.browser); + // Always print a copyable URL: spawn success does not guarantee a browser opened. + if (opened.ok) { + writeLine(stdout, `Opened registration page. If the browser did not open, visit:\n${registerUrl}`); + } else { + writeLine(stdout, `Could not open a browser (${opened.reason}). Visit:\n${registerUrl}`); + } + await prompt.question("Press Enter to return to login..."); + continue; + } + + if (choice !== "1") { + writeLine(stdout, "Please choose a valid option."); + continue; + } + + const email = (await prompt.question("Email: ")).trim(); + const password = await prompt.password("Password: "); + if (!email || !password) { + writeLine(stdout, "Email and password are required."); + continue; + } + + try { + const result = await completeInteractiveLogin({ + apiBaseUrl: options.apiBaseUrl, + email, + password, + fetchImpl: options.deps.fetchImpl, + sessionStore: options.deps.sessionStore, + ...(options.previousSession ? { previousSession: options.previousSession } : {}), + }); + if (result.warning) { + writeLine(stdout, result.warning); + } + writeLine(stdout, `Signed in as ${result.session.user.email}`); + return { + kind: "authenticated", + session: result.session, + transport: result.transport, + authClient: result.authClient, + cookieJar: result.cookieJar, + ...(result.warning ? { warning: result.warning } : {}), + }; + } catch (error) { + if (isUnreachableAuthError(error)) { + return { kind: "unreachable", error }; + } + if (error instanceof TuiAuthError && error.status === 429) { + writeLine(stdout, "Too many login attempts. Wait a moment and try again."); + continue; + } + const message = error instanceof Error ? error.message : String(error); + writeLine(stdout, `Login failed: ${sanitizeErrorMessage(message)}`); + } + } + } finally { + await prompt.close(); + } +} + +/** + * Prefer stdin TTY for readline `terminal` mode. Using stdout.isTTY breaks + * password echo suppression under `tui | tee` (stdin TTY, stdout pipe). + */ +export function resolveReadlineTerminal(stdin: NodeJS.ReadableStream): boolean { + return Boolean((stdin as NodeJS.ReadStream).isTTY); +} + +/** Password entry requires an stdin TTY that can disable echo via setRawMode. */ +export function canHidePasswordEcho(stdin: NodeJS.ReadableStream): boolean { + const stream = stdin as NodeJS.ReadStream; + return Boolean(stream.isTTY && typeof stream.setRawMode === "function"); +} + +/** + * One readline at a time on stdin. Dual visible+muted interfaces can leave the + * visible reader echoing while the muted reader collects a password. + */ +export function createSecurePrompt(options: { + stdin: NodeJS.ReadableStream; + stdout: NodeJS.WritableStream; +}): PromptFn { + const stdin = options.stdin as NodeJS.ReadStream; + const terminal = resolveReadlineTerminal(options.stdin); + const mutedStdout = new Writable({ + write(chunk, _encoding, callback) { + const text = String(chunk); + if (text.includes("\n") || text.includes("\r")) { + options.stdout.write(text.replace(/[^\r\n]/g, "")); + } + callback(); + }, + }); + + let active: Interface | undefined; + + const withReader = async ( + output: NodeJS.WritableStream, + historySize: number | undefined, + run: (rl: Interface) => Promise, + ): Promise => { + if (active) { + throw new Error("Prompt is already waiting for input."); + } + const rl = createInterface({ + input: options.stdin as NodeJS.ReadableStream, + output, + terminal, + ...(historySize !== undefined ? { historySize } : {}), + }); + active = rl; + try { + return await run(rl); + } finally { + rl.close(); + if (active === rl) { + active = undefined; + } + } + }; + + return { + question: (message) => withReader(options.stdout, undefined, (rl) => rl.question(message)), + password: async (message) => { + if (!canHidePasswordEcho(options.stdin)) { + throw new Error( + "Password input requires an interactive stdin TTY that can hide echo. " + + "Refusing to read a password that would be visible.", + ); + } + options.stdout.write(message); + const previousRaw = Boolean(stdin.isRaw); + stdin.setRawMode(true); + try { + const value = await withReader(mutedStdout, 0, (rl) => rl.question("")); + options.stdout.write("\n"); + return value; + } finally { + stdin.setRawMode(previousRaw); + } + }, + close: () => { + active?.close(); + active = undefined; + }, + }; +} + +export function isUnreachableAuthError(error: unknown): boolean { + if (error instanceof TuiAuthError) { + return error.code === "TIMEOUT" || error.code === "NETWORK_ERROR"; + } + if (!(error instanceof Error)) { + return false; + } + if (error.name === "AbortError") { + return true; + } + const message = error.message.toLowerCase(); + return ( + message.includes("fetch failed") + || message.includes("network") + || message.includes("econnrefused") + || message.includes("enotfound") + || message.includes("etimedout") + || message.includes("econnreset") + || message.includes("socket hang up") + ); +} + +function writeLine(stdout: NodeJS.WritableStream, message: string): void { + stdout.write(`${message}\n`); +} + +function sanitizeErrorMessage(message: string): string { + return message + .replace(/df_session=[^;\s]+/gi, "df_session=[redacted]") + .replace(/df_csrf=[^;\s]+/gi, "df_csrf=[redacted]"); +} diff --git a/apps/tui/src/auth/session-store.test.ts b/apps/tui/src/auth/session-store.test.ts new file mode 100644 index 00000000..591916ff --- /dev/null +++ b/apps/tui/src/auth/session-store.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + normalizeApiBaseUrl, + resolveTuiAuthStorePath, + TuiSessionStore, +} from "./session-store.js"; +import type { StoredTuiSession } from "./types.js"; + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sampleSession(apiBaseUrl: string, email = "a@example.com"): StoredTuiSession { + return { + apiBaseUrl, + cookies: { df_session: "s", df_csrf: "c" }, + user: { id: "u1", email }, + workspace: { id: "w1", name: "Personal" }, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; +} + +describe("normalizeApiBaseUrl", () => { + it("keeps deployment path and strips trailing slash", () => { + assert.equal( + normalizeApiBaseUrl("http://example.com/deploy/"), + "http://example.com/deploy", + ); + }); + + it("treats localhost and 127.0.0.1 as different keys", () => { + assert.notEqual( + normalizeApiBaseUrl("http://localhost:8787"), + normalizeApiBaseUrl("http://127.0.0.1:8787"), + ); + }); +}); + +describe("resolveTuiAuthStorePath", () => { + it("uses APPDATA on Windows", () => { + assert.equal( + resolveTuiAuthStorePath({ + platform: "win32", + env: { APPDATA: "C:\\Users\\me\\AppData\\Roaming" }, + homedir: () => "C:\\Users\\me", + }), + join("C:\\Users\\me\\AppData\\Roaming", "DataFoundry", "tui-auth.json"), + ); + }); + + it("uses XDG_CONFIG_HOME on Linux", () => { + assert.equal( + resolveTuiAuthStorePath({ + platform: "linux", + env: { XDG_CONFIG_HOME: "/tmp/xdg" }, + homedir: () => "/home/me", + }), + join("/tmp/xdg", "datafoundry", "tui-auth.json"), + ); + }); + + it("uses Application Support on macOS", () => { + assert.equal( + resolveTuiAuthStorePath({ + platform: "darwin", + env: {}, + homedir: () => "/Users/me", + }), + join("/Users/me", "Library", "Application Support", "DataFoundry", "tui-auth.json"), + ); + }); +}); + +describe("TuiSessionStore", () => { + it("isolates sessions by base URL and replaces same URL account", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-")); + const store = new TuiSessionStore({ filePath: join(dir, "tui-auth.json") }); + await store.save(sampleSession("http://127.0.0.1:8787", "old@example.com")); + await store.save(sampleSession("http://localhost:8787", "other@example.com")); + await store.save(sampleSession("http://127.0.0.1:8787", "new@example.com")); + + const local = await store.load("http://127.0.0.1:8787/"); + const loop = await store.load("http://localhost:8787"); + assert.equal(local?.user.email, "new@example.com"); + assert.equal(loop?.user.email, "other@example.com"); + }); + + it("treats corrupt JSON as missing and quarantines the file", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-corrupt-")); + const filePath = join(dir, "tui-auth.json"); + await writeFile(filePath, "{not-json", "utf8"); + const store = new TuiSessionStore({ filePath }); + assert.equal(await store.load("http://127.0.0.1:8787"), undefined); + const files = await readFile(filePath, "utf8"); + assert.match(files, /"sessions": \{\}/); + }); + + it("quarantines files with shallow/invalid session objects", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-shallow-")); + const filePath = join(dir, "tui-auth.json"); + await writeFile( + filePath, + JSON.stringify({ + version: 1, + sessions: { + "http://127.0.0.1:8787": { user: "not-an-object" }, + }, + }), + "utf8", + ); + const store = new TuiSessionStore({ filePath }); + assert.equal(await store.load("http://127.0.0.1:8787"), undefined); + assert.match(await readFile(filePath, "utf8"), /"sessions": \{\}/); + }); + + it("merges concurrent saves under file lock", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-lock-")); + const filePath = join(dir, "tui-auth.json"); + const store = new TuiSessionStore({ filePath }); + await Promise.all([ + store.save(sampleSession("http://127.0.0.1:8787", "a@example.com")), + store.save(sampleSession("http://localhost:8787", "b@example.com")), + ]); + assert.equal((await store.load("http://127.0.0.1:8787"))?.user.email, "a@example.com"); + assert.equal((await store.load("http://localhost:8787"))?.user.email, "b@example.com"); + }); + + it("writes atomically with restrictive unix permissions", async () => { + if (process.platform === "win32") { + return; + } + const dir = await mkdtemp(join(tmpdir(), "tui-session-mode-")); + const filePath = join(dir, "tui-auth.json"); + const store = new TuiSessionStore({ filePath }); + await store.save(sampleSession("http://127.0.0.1:8787")); + const fileMode = (await stat(filePath)).mode & 0o777; + const dirMode = (await stat(dir)).mode & 0o777; + assert.equal(fileMode, 0o600); + assert.equal(dirMode, 0o700); + }); + + it("rejects symlink store paths on Windows-style safety check", async () => { + if (process.platform === "win32") { + return; + } + const dir = await mkdtemp(join(tmpdir(), "tui-session-symlink-")); + const target = join(dir, "real.json"); + const link = join(dir, "tui-auth.json"); + await writeFile(target, JSON.stringify({ version: 1, sessions: {} }), "utf8"); + await symlink(target, link); + const store = new TuiSessionStore({ filePath: link }); + await assert.rejects( + () => store.save(sampleSession("http://127.0.0.1:8787")), + /regular file/i, + ); + }); + + it("reclaims stale locks that record a dead pid", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-stale-lock-")); + const filePath = join(dir, "tui-auth.json"); + const lockPath = `${filePath}.lock`; + await writeFile( + lockPath, + `${JSON.stringify({ pid: 2_147_483_647, acquiredAt: Date.now() - 60_000, token: "dead" })}\n`, + "utf8", + ); + const store = new TuiSessionStore({ filePath }); + await store.save(sampleSession("http://127.0.0.1:8787")); + assert.equal((await store.load("http://127.0.0.1:8787"))?.user.email, "a@example.com"); + }); + + it("keeps quarantine of corrupt files inside the same lock critical section", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-session-quarantine-lock-")); + const filePath = join(dir, "tui-auth.json"); + await writeFile(filePath, "{not-json", "utf8"); + const store = new TuiSessionStore({ filePath }); + + // Concurrent load (quarantine) + save must not drop the saved session. + await Promise.all([ + store.load("http://127.0.0.1:8787"), + (async () => { + await sleep(5); + await store.save(sampleSession("http://127.0.0.1:8787", "kept@example.com")); + })(), + ]); + + assert.equal( + (await store.load("http://127.0.0.1:8787"))?.user.email, + "kept@example.com", + ); + }); +}); diff --git a/apps/tui/src/auth/session-store.ts b/apps/tui/src/auth/session-store.ts new file mode 100644 index 00000000..78ffa987 --- /dev/null +++ b/apps/tui/src/auth/session-store.ts @@ -0,0 +1,415 @@ +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { homedir, platform } from "node:os"; +import type { StoredTuiSession } from "./types.js"; + +export type SessionStorePlatformEnv = { + platform?: NodeJS.Platform; + homedir?: () => string; + env?: NodeJS.ProcessEnv; +}; + +type SessionFile = { + version: 1; + sessions: Record; +}; + +type LockPayload = { + pid: number; + acquiredAt: number; + token: string; +}; + +const LOCK_TIMEOUT_MS = 5_000; +const LOCK_RETRY_MS = 20; +/** Stale lock age after which a holder is assumed dead even if the PID still exists. */ +const LOCK_STALE_MS = 10_000; + +export function normalizeApiBaseUrl(apiBaseUrl: string): string { + const url = new URL(apiBaseUrl); + url.hash = ""; + url.search = ""; + if (url.pathname !== "/" && url.pathname.endsWith("/")) { + url.pathname = url.pathname.replace(/\/+$/, ""); + } + if (url.pathname === "/") { + return `${url.protocol}//${url.host}`; + } + return `${url.protocol}//${url.host}${url.pathname}`; +} + +export function resolveTuiAuthStorePath(env: SessionStorePlatformEnv = {}): string { + const osPlatform = env.platform ?? platform(); + const home = (env.homedir ?? homedir)(); + const processEnv = env.env ?? process.env; + + if (osPlatform === "win32") { + const appData = processEnv.APPDATA?.trim(); + if (!appData) { + throw new Error("APPDATA is required to locate the TUI auth store on Windows."); + } + return join(appData, "DataFoundry", "tui-auth.json"); + } + + if (osPlatform === "darwin") { + return join(home, "Library", "Application Support", "DataFoundry", "tui-auth.json"); + } + + const xdg = processEnv.XDG_CONFIG_HOME?.trim(); + if (xdg) { + return join(xdg, "datafoundry", "tui-auth.json"); + } + return join(home, ".config", "datafoundry", "tui-auth.json"); +} + +export class TuiSessionStore { + private readonly filePath: string; + private readonly platformEnv: SessionStorePlatformEnv; + + constructor(options?: { filePath?: string; platformEnv?: SessionStorePlatformEnv }) { + this.platformEnv = options?.platformEnv ?? {}; + this.filePath = options?.filePath ?? resolveTuiAuthStorePath(this.platformEnv); + } + + /** Absolute path of the on-disk session cache (for error messages / retry UX). */ + get path(): string { + return this.filePath; + } + + async load(apiBaseUrl: string): Promise { + const key = normalizeApiBaseUrl(apiBaseUrl); + return this.withFileLock(async () => { + const file = await this.readOrRepairFile(); + const session = file.sessions[key]; + if (!session) { + return undefined; + } + return { + ...session, + apiBaseUrl: key, + }; + }); + } + + async save(session: StoredTuiSession): Promise { + await this.withFileLock(async () => { + const key = normalizeApiBaseUrl(session.apiBaseUrl); + const file = await this.readOrRepairFile(); + file.sessions[key] = { + ...session, + apiBaseUrl: key, + }; + await this.writeFileAtomic(file); + }); + } + + async remove(apiBaseUrl: string): Promise { + await this.withFileLock(async () => { + const key = normalizeApiBaseUrl(apiBaseUrl); + const file = await this.readOrRepairFile(); + if (!(key in file.sessions)) { + return; + } + delete file.sessions[key]; + await this.writeFileAtomic(file); + }); + } + + private async withFileLock(fn: () => Promise): Promise { + await this.ensureParentDir(); + const lockPath = `${this.filePath}.lock`; + const started = Date.now(); + const token = randomUUID(); + + while (true) { + try { + const handle = await open(lockPath, "wx"); + try { + const payload: LockPayload = { + pid: process.pid, + acquiredAt: Date.now(), + token, + }; + await handle.writeFile(`${JSON.stringify(payload)}\n`, "utf8"); + await handle.sync(); + return await fn(); + } finally { + await handle.close(); + await this.releaseLock(lockPath, token); + } + } catch (error) { + if (isErrno(error) && error.code === "EEXIST") { + const reclaimed = await this.tryReclaimStaleLock(lockPath); + if (reclaimed) { + continue; + } + if (Date.now() - started > LOCK_TIMEOUT_MS) { + throw new Error(`Timed out waiting for TUI auth store lock: ${lockPath}`); + } + await sleep(LOCK_RETRY_MS); + continue; + } + throw error; + } + } + } + + private async tryReclaimStaleLock(lockPath: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, "utf8"); + } catch (error) { + if (isErrno(error) && error.code === "ENOENT") { + return true; + } + return false; + } + + const payload = parseLockPayload(raw); + if (!payload || !isLockStale(payload)) { + return false; + } + + try { + await unlink(lockPath); + return true; + } catch (error) { + if (isErrno(error) && error.code === "ENOENT") { + return true; + } + return false; + } + } + + private async releaseLock(lockPath: string, token: string): Promise { + let raw: string; + try { + raw = await readFile(lockPath, "utf8"); + } catch (error) { + if (isErrno(error) && error.code === "ENOENT") { + return; + } + throw new Error( + `Failed to read TUI auth store lock for release: ${lockPath}`, + { cause: error }, + ); + } + + const payload = parseLockPayload(raw); + if (payload && payload.token !== token) { + // Another process reclaimed/replaced the lock; do not unlink. + return; + } + + try { + await unlink(lockPath); + } catch (error) { + if (isErrno(error) && error.code === "ENOENT") { + return; + } + throw new Error( + `Failed to release TUI auth store lock: ${lockPath}`, + { cause: error }, + ); + } + } + + private async readOrRepairFile(): Promise { + try { + await this.assertSafePath(); + const raw = await readFile(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + if (!isSessionFile(parsed)) { + await this.quarantineCorrupt(); + return { version: 1, sessions: {} }; + } + return parsed; + } catch (error) { + if (isErrno(error) && error.code === "ENOENT") { + return { version: 1, sessions: {} }; + } + if (error instanceof SyntaxError) { + await this.quarantineCorrupt(); + return { version: 1, sessions: {} }; + } + throw error; + } + } + + private async writeFileAtomic(file: SessionFile): Promise { + await this.ensureParentDir(); + await this.assertSafePath({ allowMissing: true }); + const tempPath = `${this.filePath}.${randomUUID()}.tmp`; + const payload = `${JSON.stringify(file, null, 2)}\n`; + const handle = await open(tempPath, "w", 0o600); + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + await rename(tempPath, this.filePath); + await chmodSafe(this.filePath, 0o600); + } + + private async ensureParentDir(): Promise { + const dir = dirname(this.filePath); + await mkdir(dir, { recursive: true, mode: 0o700 }); + await chmodSafe(dir, 0o700); + } + + /** Must run under {@link withFileLock}. */ + private async quarantineCorrupt(): Promise { + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const corruptPath = `${this.filePath}.${stamp}.corrupt`; + try { + await rename(this.filePath, corruptPath); + } catch (error) { + if (!(isErrno(error) && error.code === "ENOENT")) { + // Another process may have moved it; continue to write a clean file. + } + } + await this.writeFileAtomic({ version: 1, sessions: {} }); + } + + private async assertSafePath(options?: { allowMissing?: boolean }): Promise { + try { + const info = await lstat(this.filePath); + if (info.isSymbolicLink() || !info.isFile()) { + throw new Error("TUI auth store path must be a regular file."); + } + } catch (error) { + if (options?.allowMissing && isErrno(error) && error.code === "ENOENT") { + return; + } + if (isErrno(error) && error.code === "ENOENT") { + return; + } + throw error; + } + } +} + +function parseLockPayload(raw: string): LockPayload | undefined { + try { + const parsed = JSON.parse(raw) as unknown; + if (!isPlainObject(parsed)) { + return undefined; + } + if ( + typeof parsed.pid !== "number" + || !Number.isInteger(parsed.pid) + || parsed.pid <= 0 + || typeof parsed.acquiredAt !== "number" + || !Number.isFinite(parsed.acquiredAt) + || typeof parsed.token !== "string" + || !parsed.token + ) { + return undefined; + } + return { + pid: parsed.pid, + acquiredAt: parsed.acquiredAt, + token: parsed.token, + }; + } catch { + return undefined; + } +} + +function isLockStale(payload: LockPayload): boolean { + if (Date.now() - payload.acquiredAt > LOCK_STALE_MS) { + return true; + } + return !isProcessAlive(payload.pid); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function isSessionFile(value: unknown): value is SessionFile { + if (!isPlainObject(value)) { + return false; + } + if (value.version !== 1 || !isPlainObject(value.sessions)) { + return false; + } + for (const session of Object.values(value.sessions)) { + if (!isStoredSession(session)) { + return false; + } + } + return true; +} + +function isStoredSession(value: unknown): value is StoredTuiSession { + if (!isPlainObject(value)) { + return false; + } + if (typeof value.apiBaseUrl !== "string" || !value.apiBaseUrl.trim()) { + return false; + } + if (!isPlainObject(value.cookies)) { + return false; + } + for (const cookieValue of Object.values(value.cookies)) { + if (typeof cookieValue !== "string") { + return false; + } + } + if (!isPlainObject(value.user) + || typeof value.user.id !== "string" + || !value.user.id.trim() + || typeof value.user.email !== "string" + || !value.user.email.trim()) { + return false; + } + if (value.user.displayName !== undefined && typeof value.user.displayName !== "string") { + return false; + } + if (!isPlainObject(value.workspace) + || typeof value.workspace.id !== "string" + || !value.workspace.id.trim()) { + return false; + } + if (value.workspace.name !== undefined && typeof value.workspace.name !== "string") { + return false; + } + if (typeof value.expiresAt !== "string" || Number.isNaN(Date.parse(value.expiresAt))) { + return false; + } + return true; +} + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isErrno(error: unknown): error is NodeJS.ErrnoException { + return Boolean(error && typeof error === "object" && "code" in error); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function chmodSafe(path: string, mode: number): Promise { + if (platform() === "win32") { + return; + } + try { + const { chmod } = await import("node:fs/promises"); + await chmod(path, mode); + } catch { + // best effort on platforms without unix modes + } +} diff --git a/apps/tui/src/auth/types.ts b/apps/tui/src/auth/types.ts new file mode 100644 index 00000000..5409ef31 --- /dev/null +++ b/apps/tui/src/auth/types.ts @@ -0,0 +1,38 @@ +export type TuiUser = { + id: string; + email: string; + displayName?: string; +}; + +export type TuiWorkspace = { + id: string; + name?: string; +}; + +export type StoredTuiSession = { + apiBaseUrl: string; + cookies: Record; + user: TuiUser; + workspace: TuiWorkspace; + expiresAt: string; +}; + +export type AuthStatus = { + publicBaseUrl: string; + registrationEnabled: boolean; +}; + +export type AuthCommandController = { + logout(): Promise< + | { kind: "complete" } + | { kind: "remote-failed"; clearLocalOnly: () => Promise } + | { + kind: "local-cleanup-failed"; + storePath: string; + error: string; + retry: () => Promise; + } + >; +}; + +export type AppExitReason = "exit" | "logout" | "auth-required"; diff --git a/apps/tui/src/commands/builtinCommands.ts b/apps/tui/src/commands/builtinCommands.ts index 7f5b5383..c1955ebd 100644 --- a/apps/tui/src/commands/builtinCommands.ts +++ b/apps/tui/src/commands/builtinCommands.ts @@ -376,6 +376,18 @@ export const exitCommand: Command = { }, }; +export const logoutCommand: Command = { + name: 'logout', + description: 'Sign out of the current account', + execute: async () => { + return { + success: true, + message: 'Logging out...', + data: { action: 'logout' }, + }; + }, +}; + // Export all built-in commands function getAllCommands(): Command[] { return [ @@ -387,6 +399,7 @@ function getAllCommands(): Command[] { skillCommand, resetCommand, resumeCommand, + logoutCommand, exitCommand, ]; } diff --git a/apps/tui/src/commands/logout-command.test.ts b/apps/tui/src/commands/logout-command.test.ts new file mode 100644 index 00000000..0de1294a --- /dev/null +++ b/apps/tui/src/commands/logout-command.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { logoutCommand } from "./builtinCommands.js"; +import type { CommandContext } from "./types.js"; + +describe("logoutCommand", () => { + it("returns a logout action without touching storage itself", async () => { + const result = await logoutCommand.execute([], { + client: {}, + workspaceConfig: { db: [], llm: [], skill: [], mcp: [], knowledge: [], kb: [] }, + state: { messages: [] }, + } as unknown as CommandContext); + + assert.equal(result.success, true); + assert.deepEqual(result.data, { action: "logout" }); + assert.match(result.message, /logging out/i); + }); +}); diff --git a/apps/tui/src/commands/types.ts b/apps/tui/src/commands/types.ts index eff843d0..83fc0e3c 100644 --- a/apps/tui/src/commands/types.ts +++ b/apps/tui/src/commands/types.ts @@ -5,6 +5,7 @@ import type { DisplayMessage } from '../state/tui-state.js'; import type { WorkspaceConfigStore } from '../state/data-task-state.js'; import type { ConfigClient } from '../config/index.js'; +import type { AuthCommandController } from '../auth/types.js'; export interface CommandResult { success: boolean; @@ -22,6 +23,7 @@ export interface Command { export interface CommandContext { client: unknown; configClient?: ConfigClient | undefined; + authController?: AuthCommandController | undefined; datasourceId?: string | undefined; activeSkillId?: string | undefined; workspaceConfig: WorkspaceConfigStore; diff --git a/apps/tui/src/config/config-client.test.ts b/apps/tui/src/config/config-client.test.ts new file mode 100644 index 00000000..a94eaa41 --- /dev/null +++ b/apps/tui/src/config/config-client.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { ConfigClient } from "./config-client.js"; + +describe("ConfigClient auth transport", () => { + it("routes REST GET/POST through the injected fetchImpl only", async () => { + const calls: Array<{ method: string; url: string }> = []; + const client = new ConfigClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async (input, init) => { + calls.push({ + method: String(init?.method ?? "GET"), + url: String(input), + }); + return new Response( + JSON.stringify({ + success: true, + data: { + activeDatasourceId: "ds-1", + enabledDatasourceIds: ["ds-1"], + enabledKnowledgeIds: [], + enabledMcpServerIds: [], + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }, + }); + + await client.getRunDefaults(); + assert.equal(calls.length, 1); + assert.equal(calls[0]?.method, "GET"); + assert.match(calls[0]?.url ?? "", /\/api\/v1\/run-defaults$/); + assert.equal(calls[0]?.url.includes("cookie"), false); + }); +}); diff --git a/apps/tui/src/config/config-client.ts b/apps/tui/src/config/config-client.ts index d0b3eb2f..364edede 100644 --- a/apps/tui/src/config/config-client.ts +++ b/apps/tui/src/config/config-client.ts @@ -481,6 +481,7 @@ export type SessionTitle = z.infer; export interface ConfigClientConfig { baseUrl: string; timeout?: number | undefined; + fetchImpl?: typeof fetch | undefined; onError?: ((error: ConfigClientError) => void) | undefined; } @@ -508,11 +509,13 @@ export class ConfigClientError extends Error { export class ConfigClient { private baseUrl: string; private timeout: number; + private fetchImpl: typeof fetch; private onError?: ((error: ConfigClientError) => void) | undefined; constructor(config: ConfigClientConfig) { this.baseUrl = config.baseUrl.replace(/\/$/, ""); this.timeout = config.timeout ?? 30000; + this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis); this.onError = config.onError; } @@ -553,7 +556,7 @@ export class ConfigClient { fetchOptions.body = JSON.stringify(options.body); } - const response = await fetch(url.toString(), fetchOptions).finally(() => clearTimeout(timeoutId)); + const response = await this.fetchImpl(url.toString(), fetchOptions).finally(() => clearTimeout(timeoutId)); if (!response.ok) { throw await this.errorFromResponse(response); @@ -642,7 +645,7 @@ export class ConfigClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url.toString(), { + const response = await this.fetchImpl(url.toString(), { method, headers: { Accept: "text/plain, text/markdown, text/*, */*", @@ -919,7 +922,7 @@ export class ConfigClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url, { + const response = await this.fetchImpl(url, { method: "POST", body: formData, signal: controller.signal, @@ -975,7 +978,7 @@ export class ConfigClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url, { + const response = await this.fetchImpl(url, { method: "GET", signal: controller.signal, }).finally(() => clearTimeout(timeoutId)); @@ -1010,7 +1013,7 @@ export class ConfigClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url, { + const response = await this.fetchImpl(url, { method: "POST", body: formData, signal: controller.signal, @@ -1161,7 +1164,7 @@ export class ConfigClient { const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { - const response = await fetch(url, { + const response = await this.fetchImpl(url, { method: "POST", body: formData, signal: controller.signal, diff --git a/apps/tui/src/index-auth-wiring.test.ts b/apps/tui/src/index-auth-wiring.test.ts new file mode 100644 index 00000000..30f85434 --- /dev/null +++ b/apps/tui/src/index-auth-wiring.test.ts @@ -0,0 +1,387 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { bindTransportAuthRequired } from "./auth/index.js"; +import { TuiSessionStore } from "./auth/session-store.js"; +import { runTui } from "./index.js"; + +describe("runTui auth wiring", () => { + it("parses --no-auto-login and shares one transport fetch across clients and preflight", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-wiring-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + await sessionStore.save({ + apiBaseUrl: "http://127.0.0.1:8787", + cookies: { df_session: "old-sess", df_csrf: "old-csrf" }, + user: { id: "u0", email: "old@example.com" }, + workspace: { id: "w0" }, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + const fetchCalls: string[] = []; + const cookieSeen: string[] = []; + let logoutCookie: string | null = null; + + const fetchImpl: typeof fetch = async (input, init) => { + const url = String(input); + const headers = new Headers(init?.headers); + fetchCalls.push(`${String(init?.method ?? "GET")} ${url}`); + const cookie = headers.get("cookie"); + if (cookie) { + cookieSeen.push(`${url} => ${cookie}`); + } + + if (url.includes("/api/v1/auth/status")) { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/auth/login")) { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "user@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=s; Path=/", "df_csrf=c; Path=/"], + ); + } + if (url.includes("/api/v1/auth/logout")) { + logoutCookie = cookie; + return json(200, { success: true, data: { ok: true } }); + } + if (url.includes("/healthz")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + assert.match(cookie ?? "", /df_session=/); + return json(200, { + success: true, + data: { activeDatasourceId: "ds-1" }, + }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }; + + const answers = ["1", "user@example.com", "password"]; + let idx = 0; + + const code = await runTui({ + argv: ["--no-auto-login", "--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + fetchImpl, + sessionStore, + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async ({ configClient, client, authController, initialDatasourceId }) => { + const configFetch = (configClient as unknown as { fetchImpl: typeof fetch }).fetchImpl; + const clientFetch = (client as unknown as { fetchImpl: typeof fetch }).fetchImpl; + assert.equal(configFetch, clientFetch); + assert.equal(typeof authController.logout, "function"); + assert.equal(initialDatasourceId, "ds-1"); + return "exit"; + }, + }); + + assert.equal(code, 0); + assert.ok(fetchCalls.some((call) => call.includes("GET ") && call.includes("/api/v1/auth/status"))); + assert.ok(fetchCalls.some((call) => call.includes("POST ") && call.includes("/api/v1/auth/login"))); + assert.ok(fetchCalls.some((call) => call.includes("POST ") && call.includes("/api/v1/auth/logout"))); + assert.match(logoutCookie ?? "", /df_session=old-sess/); + assert.ok(fetchCalls.some((call) => call.includes("/api/v1/run-defaults"))); + assert.ok(cookieSeen.some((entry) => entry.includes("/api/v1/run-defaults"))); + }); + + it("retries when API is unreachable then continues login", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-unreachable-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + let statusCalls = 0; + const answers = ["r", "1", "user@example.com", "password"]; + let idx = 0; + + const code = await runTui({ + argv: ["--no-auto-login", "--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + sessionStore, + fetchImpl: async (input, init) => { + const url = String(input); + if (url.includes("/api/v1/auth/status")) { + statusCalls += 1; + if (statusCalls === 1) { + throw new TypeError("fetch failed"); + } + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/auth/login")) { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "user@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=s; Path=/", "df_csrf=c; Path=/"], + ); + } + if (url.includes("/healthz")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + return json(200, { success: true, data: { activeDatasourceId: "ds-1" } }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + prompt: { + question: async () => answers[idx++] ?? "q", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async () => "exit", + }); + + assert.equal(code, 0); + assert.equal(statusCalls, 2); + }); + + it("switches runtime URL from recovery menu without restarting", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-other-url-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + const statusHosts: string[] = []; + const answers = ["o", "http://127.0.0.1:9999/api/copilotkit", "1", "user@example.com", "password"]; + let idx = 0; + + const code = await runTui({ + argv: ["--no-auto-login", "--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + sessionStore, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes("/api/v1/auth/status")) { + statusHosts.push(new URL(url).host); + if (url.includes("127.0.0.1:8787")) { + throw new TypeError("fetch failed"); + } + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/auth/login")) { + assert.match(url, /127\.0\.0\.1:9999/); + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "user@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=s; Path=/", "df_csrf=c; Path=/"], + ); + } + if (url.includes("/healthz")) { + assert.match(url, /127\.0\.0\.1:9999/); + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + assert.match(url, /127\.0\.0\.1:9999/); + return json(200, { success: true, data: { activeDatasourceId: "ds-9" } }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + prompt: { + question: async () => answers[idx++] ?? "q", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async ({ initialDatasourceId }) => { + assert.equal(initialDatasourceId, "ds-9"); + return "exit"; + }, + }); + + assert.equal(code, 0); + assert.deepEqual(statusHosts, ["127.0.0.1:8787", "127.0.0.1:9999"]); + }); + + it("replays preflight 401 via sticky auth-required after App binds", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-preflight-401-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + await sessionStore.save({ + apiBaseUrl: "http://127.0.0.1:8787", + cookies: { df_session: "sess", df_csrf: "csrf" }, + user: { id: "u0", email: "cached@example.com" }, + workspace: { id: "w0" }, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + + let meCalls = 0; + let runDefaultsCalls = 0; + let appStarts = 0; + const answers = ["1", "fresh@example.com", "password"]; + let idx = 0; + + const code = await runTui({ + argv: ["--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + sessionStore, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes("/api/v1/auth/status")) { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/me")) { + meCalls += 1; + return json(200, { + success: true, + data: { + user: { id: "u0", email: "cached@example.com" }, + workspace: { id: "w0" }, + }, + }); + } + if (url.includes("/api/v1/auth/login")) { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "fresh@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=fresh; Path=/", "df_csrf=fresh-c; Path=/"], + ); + } + if (url.includes("/healthz")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + runDefaultsCalls += 1; + if (runDefaultsCalls === 1) { + return json(401, { error: { code: "UNAUTHORIZED" } }); + } + return json(200, { success: true, data: { activeDatasourceId: "ds-1" } }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async ({ transport }) => { + appStarts += 1; + assert.ok(transport); + let exitReason: "exit" | "auth-required" = "exit"; + const unbind = bindTransportAuthRequired(transport, () => { + exitReason = "auth-required"; + }); + unbind(); + if (appStarts === 1) { + assert.equal(exitReason, "auth-required"); + return "auth-required"; + } + assert.equal(exitReason, "exit"); + return "exit"; + }, + }); + + assert.equal(code, 0); + assert.equal(appStarts, 2); + assert.ok(meCalls >= 1); + assert.ok(runDefaultsCalls >= 2); + }); + + it("re-enters login when renderApp exits with auth-required", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-auth-required-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + const answers = ["1", "a@example.com", "pw1", "1", "b@example.com", "pw2"]; + let idx = 0; + let appStarts = 0; + + const code = await runTui({ + argv: ["--no-auto-login", "--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + sessionStore, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes("/api/v1/auth/status")) { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/auth/login")) { + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "user@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=s; Path=/", "df_csrf=c; Path=/"], + ); + } + if (url.includes("/api/v1/auth/logout")) { + return json(200, { success: true, data: { ok: true } }); + } + if (url.includes("/healthz")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + return json(200, { success: true, data: {} }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async () => { + appStarts += 1; + return appStarts === 1 ? "auth-required" : "exit"; + }, + }); + + assert.equal(code, 0); + assert.equal(appStarts, 2); + }); + + it("rejects removed offline demo mode", async () => { + const code = await runTui({ argv: [`--${"demo"}`] }); + assert.equal(code, 1); + }); +}); + +function json(status: number, body: unknown, setCookies: string[] = []): Response { + const headers = new Headers({ "content-type": "application/json" }); + for (const cookie of setCookies) { + headers.append("set-cookie", cookie); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 10e6f894..55a8c3ce 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -1,21 +1,100 @@ #!/usr/bin/env node import { render } from "ink"; import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; import React from "react"; +import { + bindTransportAuthRequired, + bootstrapTuiAuth, + createAuthController, + createSecurePrompt, + runInteractiveLogin, + type AppExitReason, + type AuthCommandController, + type AuthenticatedTransport, +} from "./auth/index.js"; import { ConfigClient } from "./config/index.js"; import { CopilotKitClient } from "./protocol/copilotkit-client.js"; -import { DemoCopilotKitClient } from "./protocol/demo-client.js"; -import { seedDemoState } from "./state/demo-state.js"; import { store } from "./state/store.js"; +import { validateRuntimeUrl } from "./runtime-url.js"; +import { + configBaseUrlFromRuntime, + preflightDefaultDatasourceId, + preflightRuntimeConnection, +} from "./startup-preflight.js"; import { installTerminalRedrawOptimizer } from "./terminal-redraw-optimizer.js"; import { withAlternateScreen } from "./terminal-screen.js"; import { App } from "./ui/App.js"; import { themeManager } from "./ui/themes/theme-manager.js"; -const args = process.argv.slice(2); -const STARTUP_PREFLIGHT_TIMEOUT_MS = 1200; +export type RunTuiOptions = { + argv?: string[]; + fetchImpl?: typeof fetch; + stdout?: NodeJS.WritableStream; + stdin?: NodeJS.ReadableStream; + prompt?: import("./auth/interactive-login.js").PromptFn; + sessionStore?: import("./auth/session-store.js").TuiSessionStore; + renderApp?: typeof renderAuthenticatedApp; +}; -if (args.includes("--help") || args.includes("-h")) { +async function renderAuthenticatedApp(options: { + client: CopilotKitClient; + configClient: ConfigClient; + datasourceId: string | undefined; + initialDatasourceId: string | undefined; + initialResume?: { enabled: boolean; sessionId?: string | undefined }; + authController: AuthCommandController; + transport?: AuthenticatedTransport; + onExit: (reason: AppExitReason) => void; +}): Promise { + let exitReason: AppExitReason = "exit"; + const restoreTerminalRedrawOptimizer = process.stdout.isTTY + ? installTerminalRedrawOptimizer(process.stdout) + : () => {}; + let unbindAuthRequired: (() => void) | undefined; + + try { + await withAlternateScreen(async () => { + const instance = render( + React.createElement(App, { + client: options.client, + configClient: options.configClient, + datasourceId: options.datasourceId, + initialDatasourceId: options.initialDatasourceId, + authController: options.authController, + onExit: (reason) => { + exitReason = reason; + options.onExit(reason); + }, + ...(options.initialResume ? { initialResume: options.initialResume } : {}), + }), + { + exitOnCtrlC: false, + incrementalRendering: + process.env.DATAFOUNDRY_TUI_INCREMENTAL_RENDERING !== "0", + maxFps: 30, + patchConsole: false, + }, + ); + if (options.transport) { + unbindAuthRequired = bindTransportAuthRequired(options.transport, () => { + if (exitReason === "exit") { + exitReason = "auth-required"; + } + instance.unmount(); + }); + } + await instance.waitUntilExit(); + }); + } finally { + unbindAuthRequired?.(); + restoreTerminalRedrawOptimizer(); + } + + return exitReason; +} + +function printHelp(): void { console.log(` DataFoundry TUI - Terminal User Interface for DataFoundry @@ -26,13 +105,13 @@ Options: --runtime-url CopilotKit runtime URL (default: http://127.0.0.1:8787/api/copilotkit) --datasource-id Datasource ID - (default: backend run-defaults; demo: api-duckdb-demo) + (default: backend run-defaults) --agent Agent name (default: dataFoundry) --theme TUI color theme (mist-dark or legacy-dark; env: DATAFOUNDRY_TUI_THEME) --resume [sessionId] Resume the latest server session, or a specific session - --demo Show mock messages and use a local mock stream + --no-auto-login Ignore cached session and show the login menu --help, -h Show this help message Examples: @@ -42,20 +121,23 @@ Examples: datafoundry-tui --theme mist-dark datafoundry-tui --resume datafoundry-tui --resume thread-001 - datafoundry-tui --demo + datafoundry-tui --no-auto-login + +Notes: + Session cache keys treat localhost, 127.0.0.1, and ::1 as different API endpoints. + Keep --runtime-url consistent with the address you used when signing in. `); - process.exit(0); } -function getArg(name: string, defaultValue: string): string { +function getArg(args: string[], name: string, defaultValue: string): string { const index = args.indexOf(name); if (index !== -1 && index + 1 < args.length) { - return args[index + 1]; + return args[index + 1]!; } return defaultValue; } -function getOptionalArg(name: string): string | undefined { +function getOptionalArg(args: string[], name: string): string | undefined { const index = args.indexOf(name); if (index === -1 || index + 1 >= args.length) { return undefined; @@ -64,134 +146,165 @@ function getOptionalArg(name: string): string | undefined { return next && !next.startsWith("-") ? next : undefined; } -const requestedTheme = getOptionalArg("--theme") ?? process.env.DATAFOUNDRY_TUI_THEME; -if (requestedTheme && !themeManager.setActiveTheme(requestedTheme)) { - const availableThemes = themeManager.getAvailableThemes().map((theme) => theme.name).join(", "); - console.error(`Unknown TUI theme "${requestedTheme}". Available themes: ${availableThemes}.`); - process.exit(1); -} - -function resolveResumeRequest(): { enabled: boolean; sessionId?: string | undefined } | undefined { +function resolveResumeRequest( + args: string[], +): { enabled: boolean; sessionId?: string | undefined } | undefined { if (args.includes("--resume")) { return { enabled: true, - ...(getOptionalArg("--resume") ? { sessionId: getOptionalArg("--resume") } : {}), + ...(getOptionalArg(args, "--resume") + ? { sessionId: getOptionalArg(args, "--resume") } + : {}), }; } - const explicit = getOptionalArg("--resume-session"); + const explicit = getOptionalArg(args, "--resume-session"); return explicit ? { enabled: true, sessionId: explicit } : undefined; } -function configBaseUrlFromRuntime(runtimeUrl: string): string { - const apiIndex = runtimeUrl.indexOf("/api/"); - if (apiIndex >= 0) { - return runtimeUrl.slice(0, apiIndex); - } - return runtimeUrl.replace(/\/$/, ""); -} - -async function fetchWithStartupTimeout(url: string): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), STARTUP_PREFLIGHT_TIMEOUT_MS); +export async function runTui(options: RunTuiOptions = {}): Promise { + const args = options.argv ?? process.argv.slice(2); + const fetchImpl = options.fetchImpl ?? globalThis.fetch.bind(globalThis); + const renderApp = options.renderApp ?? renderAuthenticatedApp; - try { - return await fetch(url, { - method: "GET", - signal: controller.signal, - }); - } catch { - return undefined; - } finally { - clearTimeout(timeoutId); + if (args.includes("--help") || args.includes("-h")) { + printHelp(); + return 0; } -} -async function preflightRuntimeConnection(runtimeUrl: string): Promise { - const response = await fetchWithStartupTimeout(runtimeUrl.replace(/\/api\/.*$/, "/healthz")); - return response?.ok === true; -} - -function objectRecord(value: unknown): Record | undefined { - return value && typeof value === "object" ? value as Record : undefined; -} - -function datasourceIdFromRunDefaults(value: unknown): string | undefined { - const envelope = objectRecord(value); - const data = objectRecord(envelope?.success === true ? envelope.data : value); - const activeDatasourceId = data?.activeDatasourceId; + const legacyOfflineDemoFlag = `--${"demo"}`; + if (args.includes(legacyOfflineDemoFlag)) { + console.error( + "Offline demo mode has been removed. Sign in with a password account against a running API.", + ); + return 1; + } - if (typeof activeDatasourceId === "string" && activeDatasourceId.trim()) { - return activeDatasourceId; + const requestedTheme = getOptionalArg(args, "--theme") ?? process.env.DATAFOUNDRY_TUI_THEME; + if (requestedTheme && !themeManager.setActiveTheme(requestedTheme)) { + const availableThemes = themeManager.getAvailableThemes().map((theme) => theme.name).join(", "); + console.error(`Unknown TUI theme "${requestedTheme}". Available themes: ${availableThemes}.`); + return 1; } - const enabledDatasourceIds = data?.enabledDatasourceIds; - if (Array.isArray(enabledDatasourceIds)) { - const firstEnabled = enabledDatasourceIds.find( - (item): item is string => typeof item === "string" && item.trim().length > 0, - ); - return firstEnabled; + const initialRuntime = validateRuntimeUrl( + getArg(args, "--runtime-url", "http://127.0.0.1:8787/api/copilotkit"), + ); + if (!initialRuntime.ok) { + console.error(initialRuntime.reason); + return 1; } + let runtimeUrl = initialRuntime.url; + let configBaseUrl = configBaseUrlFromRuntime(runtimeUrl); + const explicitDatasourceId = getOptionalArg(args, "--datasource-id"); + const agent = getArg(args, "--agent", "dataFoundry"); + const noAutoLogin = args.includes("--no-auto-login"); + const initialResume = resolveResumeRequest(args); - return undefined; -} + const stdout = options.stdout ?? process.stdout; + const stdin = options.stdin ?? process.stdin; -async function preflightDefaultDatasourceId(baseUrl: string): Promise { - const response = await fetchWithStartupTimeout(`${baseUrl}/api/v1/run-defaults`); - if (!response?.ok) { - return undefined; - } + while (true) { + let bootstrap: Awaited>; + try { + bootstrap = await bootstrapTuiAuth({ + apiBaseUrl: configBaseUrl, + noAutoLogin, + fetchImpl, + ...(options.sessionStore ? { sessionStore: options.sessionStore } : {}), + }); + } catch (error) { + const recovered = await promptApiUnreachableRecovery({ + apiBaseUrl: configBaseUrl, + error, + stdout, + stdin, + ...(options.prompt ? { prompt: options.prompt } : {}), + }); + if (recovered.action === "quit") { + return 1; + } + if (recovered.action === "other-address") { + runtimeUrl = recovered.runtimeUrl; + configBaseUrl = configBaseUrlFromRuntime(runtimeUrl); + } + continue; + } - try { - return datasourceIdFromRunDefaults(await response.json()); - } catch { - return undefined; - } -} + if (bootstrap.kind === "login-required") { + const interactive = await runInteractiveLogin({ + apiBaseUrl: configBaseUrl, + status: bootstrap.status, + ...(bootstrap.previousSession + ? { previousSession: bootstrap.previousSession } + : {}), + deps: { + fetchImpl, + sessionStore: bootstrap.sessionStore, + ...(options.stdout ? { stdout: options.stdout } : {}), + ...(options.stdin ? { stdin: options.stdin } : {}), + ...(options.prompt ? { prompt: options.prompt } : {}), + }, + }); + if (interactive.kind === "exit") { + return 0; + } + if (interactive.kind === "unreachable") { + const recovered = await promptApiUnreachableRecovery({ + apiBaseUrl: configBaseUrl, + error: interactive.error, + stdout, + stdin, + ...(options.prompt ? { prompt: options.prompt } : {}), + }); + if (recovered.action === "quit") { + return 1; + } + if (recovered.action === "other-address") { + runtimeUrl = recovered.runtimeUrl; + configBaseUrl = configBaseUrlFromRuntime(runtimeUrl); + } + continue; + } + bootstrap = { + kind: "authenticated", + session: interactive.session, + transport: interactive.transport, + authClient: interactive.authClient, + cookieJar: interactive.cookieJar, + sessionStore: bootstrap.sessionStore, + ...(interactive.warning ? { warning: interactive.warning } : {}), + }; + } -const runtimeUrl = getArg( - "--runtime-url", - "http://127.0.0.1:8787/api/copilotkit" -); -const configBaseUrl = configBaseUrlFromRuntime(runtimeUrl); -const explicitDatasourceId = getOptionalArg("--datasource-id"); -const agent = getArg("--agent", "dataFoundry"); -const demoMode = args.includes("--demo"); -const demoDatasourceId = explicitDatasourceId ?? "api-duckdb-demo"; -const datasourceId = demoMode ? demoDatasourceId : explicitDatasourceId; -let initialDatasourceId = datasourceId; -const initialResume = resolveResumeRequest(); -const configClient = demoMode - ? undefined - : new ConfigClient({ - baseUrl: configBaseUrl, + if (bootstrap.kind !== "authenticated") { + return 1; + } + + const transportFetch = bootstrap.transport.fetch.bind(bootstrap.transport); + const authController = createAuthController({ + apiBaseUrl: configBaseUrl, + authClient: bootstrap.authClient, + sessionStore: bootstrap.sessionStore, }); -const client = demoMode - ? new DemoCopilotKitClient() - : new CopilotKitClient({ + const configClient = new ConfigClient({ + baseUrl: configBaseUrl, + fetchImpl: transportFetch, + }); + const client = new CopilotKitClient({ runtimeUrl, agent, + fetchImpl: transportFetch, onConnectionStatusChange: (status) => { store.setConnectionStatus(status === "reconnecting" ? "disconnected" : status); }, }); -function createAppElement(): React.ReactElement { - return React.createElement(App, { - client, - datasourceId, - initialDatasourceId, - ...(configClient ? { configClient } : {}), - ...(initialResume ? { initialResume } : {}), - }); -} - -async function main(): Promise { - try { + let initialDatasourceId = explicitDatasourceId; const [runtimeConnected, preflightDatasourceId] = await Promise.all([ - demoMode ? Promise.resolve(true) : preflightRuntimeConnection(runtimeUrl), - !demoMode && !initialDatasourceId - ? preflightDefaultDatasourceId(configBaseUrl) + preflightRuntimeConnection(runtimeUrl, transportFetch), + !initialDatasourceId + ? preflightDefaultDatasourceId(configBaseUrl, transportFetch) : Promise.resolve(undefined), ]); @@ -203,34 +316,115 @@ async function main(): Promise { if (!initialResume?.enabled) { store.setThreadId(randomUUID()); } - if (demoMode) { - seedDemoState(demoDatasourceId); + + if (bootstrap.warning) { + console.log(bootstrap.warning); } + console.log( + `Authenticated as ${bootstrap.session.user.email} (${bootstrap.session.workspace.id})`, + ); - const restoreTerminalRedrawOptimizer = process.stdout.isTTY - ? installTerminalRedrawOptimizer(process.stdout) - : () => {}; + const exitReason = await renderApp({ + client, + configClient, + datasourceId: explicitDatasourceId, + initialDatasourceId, + authController, + transport: bootstrap.transport, + onExit: () => {}, + ...(initialResume ? { initialResume } : {}), + }); - try { - await withAlternateScreen(async () => { - const instance = render(createAppElement(), { - exitOnCtrlC: false, - // Ink 7 fixes the trailing-newline cursor offset in its line-diff - // renderer. Keep an opt-out for terminal-specific rendering issues. - incrementalRendering: - process.env.DATAFOUNDRY_TUI_INCREMENTAL_RENDERING !== "0", - maxFps: 30, - patchConsole: false, - }); - await instance.waitUntilExit(); - }); - } finally { - restoreTerminalRedrawOptimizer(); + if ("dispose" in client && typeof client.dispose === "function") { + client.dispose(); } - } catch (error) { - console.error("Failed to start TUI:", error); - process.exitCode = 1; + + if (exitReason === "logout" || exitReason === "auth-required") { + store.reset(); + if (exitReason === "auth-required") { + console.log("Session expired or revoked. Please sign in again."); + } + // Re-enter auth menu; force interactive login for account switching clarity. + continue; + } + return 0; } } -await main(); +type ApiUnreachableRecovery = + | { action: "retry" } + | { action: "quit" } + | { action: "other-address"; runtimeUrl: string }; + +async function promptApiUnreachableRecovery(options: { + apiBaseUrl: string; + error: unknown; + stdout: NodeJS.WritableStream; + stdin: NodeJS.ReadableStream; + prompt?: import("./auth/interactive-login.js").PromptFn; +}): Promise { + const message = errorMessage(options.error); + options.stdout.write( + `Cannot reach API at ${options.apiBaseUrl}: ${message}\n` + + "Options: [r]etry, [o]ther address, [q]uit\n", + ); + + const ownsPrompt = !options.prompt; + const prompt = options.prompt ?? createSecurePrompt({ + stdin: options.stdin, + stdout: options.stdout, + }); + try { + while (true) { + const answer = (await prompt.question("Select an option: ")).trim().toLowerCase(); + if (answer === "r" || answer === "retry" || answer === "") { + return { action: "retry" }; + } + if (answer === "o" || answer === "other" || answer === "address") { + const runtimeUrl = await promptRuntimeUrl(prompt, options.stdout); + if (runtimeUrl) { + return { action: "other-address", runtimeUrl }; + } + continue; + } + if (answer === "q" || answer === "quit" || answer === "exit") { + return { action: "quit" }; + } + options.stdout.write("Please choose [r]etry, [o]ther address, or [q]uit.\n"); + } + } finally { + if (ownsPrompt) { + await prompt.close(); + } + } +} + +async function promptRuntimeUrl( + prompt: import("./auth/interactive-login.js").PromptFn, + stdout: NodeJS.WritableStream, +): Promise { + while (true) { + const raw = (await prompt.question("Enter runtime URL (empty to cancel): ")).trim(); + if (!raw) { + return undefined; + } + const validated = validateRuntimeUrl(raw); + if (validated.ok) { + return validated.url; + } + stdout.write(`${validated.reason}\n`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +const isDirectLaunch = process.argv[1] + ? import.meta.url === pathToFileURL(process.argv[1]).href + : false; + +if (isDirectLaunch) { + const code = await runTui(); + process.exitCode = code; +} diff --git a/apps/tui/src/no-bare-fetch.guard.test.ts b/apps/tui/src/no-bare-fetch.guard.test.ts new file mode 100644 index 00000000..3255293d --- /dev/null +++ b/apps/tui/src/no-bare-fetch.guard.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { basename, dirname, join, relative } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; + +const moduleDir = dirname(fileURLToPath(import.meta.url)); +const packageRoot = basename(moduleDir) === "dist" || basename(moduleDir) === "src" + ? join(moduleDir, "..") + : moduleDir; +const srcRoot = join(packageRoot, "src"); +const repoRoot = join(packageRoot, "..", ".."); + +function walk(dir: string, predicate: (name: string) => boolean): string[] { + if (!existsSync(dir)) { + return []; + } + const entries = readdirSync(dir); + const files: string[] = []; + for (const entry of entries) { + if (entry === "node_modules" || entry === "dist" || entry === ".git") { + continue; + } + const full = join(dir, entry); + const info = statSync(full); + if (info.isDirectory()) { + files.push(...walk(full, predicate)); + } else if (predicate(entry)) { + files.push(full); + } + } + return files; +} + +describe("no bare fetch on protected TUI API paths", () => { + it("keeps production modules on injected fetch / AuthenticatedTransport", () => { + const allowed = new Set([ + "auth/authenticated-transport.ts", + "auth/auth-client.ts", + ]); + const offenders: string[] = []; + const files = walk(srcRoot, (name) => /\.(ts|tsx)$/.test(name)); + assert.ok(files.length > 0, `expected source files under ${srcRoot}`); + + for (const file of files) { + const rel = relative(srcRoot, file).replace(/\\/g, "/"); + if (rel.endsWith(".test.ts") || rel.endsWith(".test.tsx")) { + continue; + } + if (allowed.has(rel)) { + continue; + } + const source = readFileSync(file, "utf8"); + if (/\bawait\s+fetch\s*\(/.test(source) || /\bfetch\s*\(\s*[`'"][^`'"]*\/api\//.test(source)) { + offenders.push(rel); + } + if (/\bglobalThis\.fetch\s*\(/.test(source) && !rel.includes("auth/")) { + // Defaults in constructors are OK; bare invocation is not. + if (/globalThis\.fetch\s*\([^)]*\/api\//.test(source)) { + offenders.push(rel); + } + } + } + + assert.deepEqual(offenders, []); + }); + + it("forbids offline demo symbols in apps, README, and docs", () => { + const banned = /DemoCopilotKitClient|seedDemoState|--demo/; + const offenders: string[] = []; + const roots = [ + join(repoRoot, "apps"), + join(repoRoot, "docs"), + join(repoRoot, "README.md"), + ]; + + const files: string[] = []; + for (const root of roots) { + if (!existsSync(root)) { + continue; + } + const info = statSync(root); + if (info.isFile()) { + files.push(root); + continue; + } + files.push( + ...walk(root, (name) => /\.(ts|tsx|js|mjs|cjs|md|mdx)$/.test(name)), + ); + } + assert.ok(files.length > 0, "expected apps/docs/README files to scan"); + + for (const file of files) { + const rel = relative(repoRoot, file).replace(/\\/g, "/"); + // Guard + intentional CLI rejection string-split must keep mentioning the flag. + if ( + rel.endsWith("no-bare-fetch.guard.test.ts") + || rel.endsWith("no-bare-fetch.guard.test.js") + ) { + continue; + } + const source = readFileSync(file, "utf8"); + if (banned.test(source)) { + offenders.push(rel); + } + } + + assert.deepEqual(offenders, []); + }); +}); diff --git a/apps/tui/src/protocol/client.ts b/apps/tui/src/protocol/client.ts index d45e4edb..db9a5547 100644 --- a/apps/tui/src/protocol/client.ts +++ b/apps/tui/src/protocol/client.ts @@ -1,5 +1,4 @@ // Re-export CopilotKit client for backward compatibility export { CopilotKitClient, CopilotKitClientError } from "./copilotkit-client.js"; export type { CopilotKitClientConfig } from "./copilotkit-client.js"; -export { DemoCopilotKitClient } from "./demo-client.js"; export * from "./types.js"; diff --git a/apps/tui/src/protocol/copilotkit-client-auth.test.ts b/apps/tui/src/protocol/copilotkit-client-auth.test.ts new file mode 100644 index 00000000..4dda330d --- /dev/null +++ b/apps/tui/src/protocol/copilotkit-client-auth.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { CopilotKitClient } from "./copilotkit-client.js"; + +describe("CopilotKitClient auth transport", () => { + it("routes AG-UI POST through the injected fetchImpl only", async () => { + const calls: string[] = []; + const client = new CopilotKitClient({ + runtimeUrl: "http://127.0.0.1:8787/api/copilotkit", + agent: "dataFoundry", + fetchImpl: async (input, init) => { + calls.push(`${String(init?.method ?? "GET")} ${String(input)}`); + return new Response("event: RUN_FINISHED\ndata: {}\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + + const response = await (client as unknown as { + postRunAgent: (input: unknown) => Promise; + }).postRunAgent({ + threadId: "t1", + runId: "r1", + messages: [], + tools: [], + context: [], + state: {}, + forwardedProps: {}, + }); + + assert.equal(response.status, 200); + assert.deepEqual(calls, ["POST http://127.0.0.1:8787/api/copilotkit"]); + }); +}); diff --git a/apps/tui/src/protocol/copilotkit-client.ts b/apps/tui/src/protocol/copilotkit-client.ts index 61f8c4cb..bf29f52d 100644 --- a/apps/tui/src/protocol/copilotkit-client.ts +++ b/apps/tui/src/protocol/copilotkit-client.ts @@ -15,6 +15,7 @@ import { export interface CopilotKitClientConfig { runtimeUrl: string; agent: string; + fetchImpl?: typeof fetch | undefined; maxRetries?: number | undefined; retryBaseDelay?: number | undefined; connectionCheckInterval?: number | undefined; @@ -43,6 +44,7 @@ type SingleRouteEnvelope = { export class CopilotKitClient { private runtimeUrl: string; private agent: string; + private fetchImpl: typeof fetch; private maxRetries: number; private retryBaseDelay: number; private connectionCheckInterval: number; @@ -54,6 +56,7 @@ export class CopilotKitClient { constructor(config: CopilotKitClientConfig) { this.runtimeUrl = config.runtimeUrl; this.agent = config.agent; + this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis); this.maxRetries = config.maxRetries ?? 3; this.retryBaseDelay = config.retryBaseDelay ?? 1000; this.connectionCheckInterval = config.connectionCheckInterval ?? 30000; @@ -91,7 +94,7 @@ export class CopilotKitClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch(this.runtimeUrl.replace(/\/api\/.*$/, '/healthz'), { + const response = await this.fetchImpl(this.runtimeUrl.replace(/\/api\/.*$/, '/healthz'), { method: 'GET', signal: controller.signal, }).finally(() => clearTimeout(timeoutId)); @@ -228,7 +231,7 @@ export class CopilotKitClient { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 60000); // 60s timeout - response = await fetch(this.runtimeUrl, { + response = await this.fetchImpl(this.runtimeUrl, { method: "POST", headers: { "Content-Type": "application/json", diff --git a/apps/tui/src/protocol/demo-client.ts b/apps/tui/src/protocol/demo-client.ts deleted file mode 100644 index bb8edab2..00000000 --- a/apps/tui/src/protocol/demo-client.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { AgentClient, CopilotKitEvent, RunAgentInput } from "./types.js"; - -const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -function event(value: Record): CopilotKitEvent { - return value as CopilotKitEvent; -} - -function latestUserText(input: RunAgentInput): string { - const lastMessage = [...input.messages].reverse().find((message) => message.role === "user"); - if (!lastMessage) return "Show demo sales data"; - return typeof lastMessage.content === "string" ? lastMessage.content : "Show demo sales data"; -} - -export class DemoCopilotKitClient implements AgentClient { - async *runAgent(input: RunAgentInput): AsyncGenerator { - const question = latestUserText(input); - const datasourceId = - typeof input.forwardedProps?.datasourceId === "string" - ? input.forwardedProps.datasourceId - : "api-duckdb-demo"; - const messageId = `demo-message-${Date.now()}`; - - yield event({ - type: "RUN_STARTED", - threadId: input.threadId, - runId: input.runId, - input, - }); - - yield event({ - type: "ACTIVITY_SNAPSHOT", - activityType: "PLAN", - messageId: "demo-plan", - content: { - tasks: [ - { id: "inspect", title: "Inspect datasource schema", status: "completed" }, - { id: "query", title: "Run read-only SQL", status: "running" }, - { id: "final", title: "Summarize results", status: "pending" }, - ], - }, - }); - - yield event({ - type: "REASONING_MESSAGE_START", - messageId: `${messageId}-reasoning-1`, - }); - yield event({ - type: "REASONING_MESSAGE_CONTENT", - messageId: `${messageId}-reasoning-1`, - delta: "I should inspect the datasource schema first, then run a narrow aggregate query.", - }); - yield event({ - type: "REASONING_MESSAGE_END", - messageId: `${messageId}-reasoning-1`, - }); - - yield event({ - type: "TOOL_CALL_START", - toolCallId: "demo-schema", - toolCallName: "inspect_schema", - args: { - datasource_id: datasourceId, - table_names: ["orders"], - }, - }); - await delay(180); - yield event({ - type: "TOOL_CALL_RESULT", - toolCallId: "demo-schema", - toolCallName: "inspect_schema", - content: JSON.stringify({ - tables: [ - { - name: "orders", - columns: [ - { name: "order_date", type: "DATE" }, - { name: "region", type: "TEXT" }, - { name: "revenue", type: "DOUBLE" }, - ], - }, - ], - }), - }); - - yield event({ - type: "TOOL_CALL_START", - toolCallId: "demo-sql", - toolCallName: "run_sql_readonly", - args: { - sql: "select region, sum(revenue) as revenue from orders group by region order by revenue desc limit 5", - }, - }); - await delay(220); - yield event({ - type: "CUSTOM", - name: "sql_audit", - value: { - audit_log_id: "demo-audit-1", - datasource_id: datasourceId, - status: "success", - row_count: 5, - elapsed_ms: 84, - }, - }); - yield event({ - type: "TOOL_CALL_RESULT", - toolCallId: "demo-sql", - toolCallName: "run_sql_readonly", - content: JSON.stringify({ - row_count: 5, - elapsed_ms: 84, - }), - }); - - yield event({ - type: "CUSTOM", - name: "artifact", - value: { - id: "demo-top-regions", - type: "table", - title: "Top regions by revenue", - summary: "Demo table generated from a mock read-only SQL query.", - preview_json: { - columns: ["region", "revenue"], - rows: [ - ["East", "1,284,000"], - ["South", "973,500"], - ["North", "812,300"], - ], - }, - }, - }); - - const chunks = [ - `Demo response for: "${question}". `, - "I inspected the schema, ran a read-only aggregate query, ", - "and found East leading revenue in the sample data. ", - "The artifact section now shows a small preview table.", - ]; - for (const delta of chunks) { - await delay(180); - yield event({ - type: "TEXT_MESSAGE_CONTENT", - messageId, - delta, - }); - } - - yield event({ - type: "TEXT_MESSAGE_END", - messageId, - }); - yield event({ - type: "ACTIVITY_SNAPSHOT", - activityType: "PLAN", - messageId: "demo-plan", - content: { - tasks: [ - { id: "inspect", title: "Inspect datasource schema", status: "completed" }, - { id: "query", title: "Run read-only SQL", status: "completed" }, - { id: "final", title: "Summarize results", status: "completed" }, - ], - }, - }); - yield event({ - type: "RUN_FINISHED", - threadId: input.threadId, - runId: input.runId, - outcome: { type: "success" }, - }); - } -} diff --git a/apps/tui/src/protocol/index.ts b/apps/tui/src/protocol/index.ts index ac3117ad..3a06ae5d 100644 --- a/apps/tui/src/protocol/index.ts +++ b/apps/tui/src/protocol/index.ts @@ -1,5 +1,4 @@ // Protocol module exports export { CopilotKitClient, CopilotKitClientError } from "./copilotkit-client.js"; export type { CopilotKitClientConfig } from "./copilotkit-client.js"; -export { DemoCopilotKitClient } from "./demo-client.js"; export * from "./types.js"; diff --git a/apps/tui/src/runtime-url.test.ts b/apps/tui/src/runtime-url.test.ts new file mode 100644 index 00000000..4d123300 --- /dev/null +++ b/apps/tui/src/runtime-url.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { isLoopbackHostname, validateRuntimeUrl } from "./runtime-url.js"; + +describe("validateRuntimeUrl", () => { + it("allows loopback HTTP and normalizes path/query/hash", () => { + const result = validateRuntimeUrl( + "http://127.0.0.1:8787/api/copilotkit/?x=1#frag", + ); + assert.deepEqual(result, { + ok: true, + url: "http://127.0.0.1:8787/api/copilotkit", + }); + }); + + it("allows localhost HTTP and remote HTTPS", () => { + assert.equal(validateRuntimeUrl("http://localhost:8787/api/copilotkit").ok, true); + assert.equal( + validateRuntimeUrl("https://api.example.com/deploy/api/copilotkit/").ok, + true, + ); + assert.equal( + (validateRuntimeUrl("https://api.example.com/deploy/api/copilotkit/") as { url: string }).url, + "https://api.example.com/deploy/api/copilotkit", + ); + }); + + it("rejects non-loopback plaintext HTTP", () => { + const result = validateRuntimeUrl("http://api.example.com/api/copilotkit"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.reason, /HTTPS/i); + } + }); + + it("rejects URL credentials", () => { + const result = validateRuntimeUrl("https://user:pass@example.com/api/copilotkit"); + assert.equal(result.ok, false); + if (!result.ok) { + assert.match(result.reason, /credentials/i); + } + }); + + it("rejects non-http(s) schemes", () => { + const result = validateRuntimeUrl("ftp://127.0.0.1/api/copilotkit"); + assert.equal(result.ok, false); + }); +}); + +describe("isLoopbackHostname", () => { + it("recognizes common loopback forms", () => { + assert.equal(isLoopbackHostname("localhost"), true); + assert.equal(isLoopbackHostname("127.0.0.1"), true); + assert.equal(isLoopbackHostname("127.1.2.3"), true); + assert.equal(isLoopbackHostname("::1"), true); + assert.equal(isLoopbackHostname("[::1]"), true); + assert.equal(isLoopbackHostname("example.com"), false); + assert.equal(isLoopbackHostname("192.168.0.1"), false); + }); +}); diff --git a/apps/tui/src/runtime-url.ts b/apps/tui/src/runtime-url.ts new file mode 100644 index 00000000..585b27b8 --- /dev/null +++ b/apps/tui/src/runtime-url.ts @@ -0,0 +1,63 @@ +/** + * Validate and normalize a CopilotKit runtime URL for TUI startup / recovery. + * + * Rules: + * - http/https only + * - no URL credentials + * - clear query/hash; strip trailing slash on pathname (except root) + * - plaintext HTTP only for loopback; non-loopback must use HTTPS + */ +export function validateRuntimeUrl( + raw: string, +): { ok: true; url: string } | { ok: false; reason: string } { + let parsed: URL; + try { + parsed = new URL(raw.trim()); + } catch { + return { ok: false, reason: "Invalid URL. Try again." }; + } + + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false, reason: "URL must use http:// or https://." }; + } + + if (parsed.username || parsed.password) { + return { ok: false, reason: "URL must not include credentials." }; + } + + const hostname = normalizeHostname(parsed.hostname); + if (parsed.protocol === "http:" && !isLoopbackHostname(hostname)) { + return { + ok: false, + reason: + "HTTP is only allowed for loopback addresses (localhost / 127.0.0.1 / ::1). Use HTTPS for remote hosts.", + }; + } + + parsed.hash = ""; + parsed.search = ""; + if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) { + parsed.pathname = parsed.pathname.replace(/\/+$/, ""); + } + + return { ok: true, url: parsed.toString() }; +} + +export function isLoopbackHostname(hostname: string): boolean { + const host = normalizeHostname(hostname); + if (host === "localhost" || host === "::1") { + return true; + } + if (/^127(?:\.\d{1,3}){3}$/.test(host)) { + return true; + } + // IPv4-mapped IPv6 loopback, e.g. :ffff:127.0.0.1 + if (host.startsWith(":ffff:127.")) { + return true; + } + return false; +} + +function normalizeHostname(hostname: string): string { + return hostname.replace(/^\[|\]$/g, "").toLowerCase(); +} diff --git a/apps/tui/src/startup-preflight.test.ts b/apps/tui/src/startup-preflight.test.ts new file mode 100644 index 00000000..3e0d264c --- /dev/null +++ b/apps/tui/src/startup-preflight.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + configBaseUrlFromRuntime, + fetchJsonWithStartupTimeout, + preflightDefaultDatasourceId, + preflightRuntimeConnection, +} from "./startup-preflight.js"; + +describe("startup preflight auth transport", () => { + it("uses the injected fetch for healthz and run-defaults", async () => { + const calls: string[] = []; + const fetchImpl: typeof fetch = async (input) => { + calls.push(String(input)); + if (String(input).endsWith("/healthz")) { + return new Response("ok", { status: 200 }); + } + return new Response( + JSON.stringify({ + success: true, + data: { activeDatasourceId: "ds-auth" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + assert.equal( + await preflightRuntimeConnection("http://127.0.0.1:8787/api/copilotkit", fetchImpl), + true, + ); + assert.equal( + await preflightDefaultDatasourceId("http://127.0.0.1:8787", fetchImpl), + "ds-auth", + ); + assert.deepEqual(calls, [ + "http://127.0.0.1:8787/healthz", + "http://127.0.0.1:8787/api/v1/run-defaults", + ]); + }); + + it("keeps startup timeout armed through JSON body read", async () => { + let abortedDuringBody = false; + const result = await fetchJsonWithStartupTimeout( + "http://127.0.0.1/api/v1/run-defaults", + async (_input, init) => + new Response( + new ReadableStream({ + start(controller) { + const timer = setTimeout(() => { + controller.enqueue( + new TextEncoder().encode( + JSON.stringify({ + success: true, + data: { activeDatasourceId: "late" }, + }), + ), + ); + controller.close(); + }, 2_000); + init?.signal?.addEventListener("abort", () => { + abortedDuringBody = true; + clearTimeout(timer); + try { + controller.error(Object.assign(new Error("aborted"), { name: "AbortError" })); + } catch { + // already closed + } + }); + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + assert.equal(result, undefined); + assert.equal(abortedDuringBody, true); + }); + + it("derives config base URL from runtime URL", () => { + assert.equal( + configBaseUrlFromRuntime("http://127.0.0.1:8787/api/copilotkit"), + "http://127.0.0.1:8787", + ); + }); +}); diff --git a/apps/tui/src/startup-preflight.ts b/apps/tui/src/startup-preflight.ts new file mode 100644 index 00000000..2629ee5a --- /dev/null +++ b/apps/tui/src/startup-preflight.ts @@ -0,0 +1,110 @@ +const STARTUP_PREFLIGHT_TIMEOUT_MS = 1200; + +export async function fetchWithStartupTimeout( + url: string, + fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis), +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), STARTUP_PREFLIGHT_TIMEOUT_MS); + + try { + return await fetchImpl(url, { + method: "GET", + signal: controller.signal, + }); + } catch { + return undefined; + } finally { + clearTimeout(timeoutId); + } +} + +/** + * Like {@link fetchWithStartupTimeout}, but keeps the AbortController armed until + * response body JSON parsing finishes (not only until headers arrive). + */ +export async function fetchJsonWithStartupTimeout( + url: string, + fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis), +): Promise<{ ok: boolean; body: unknown } | undefined> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), STARTUP_PREFLIGHT_TIMEOUT_MS); + + try { + const response = await fetchImpl(url, { + method: "GET", + signal: controller.signal, + }); + if (!response.ok) { + return { ok: false, body: undefined }; + } + const body = await response.json(); + return { ok: true, body }; + } catch { + return undefined; + } finally { + clearTimeout(timeoutId); + } +} + +export async function preflightRuntimeConnection( + runtimeUrl: string, + fetchImpl: typeof fetch, +): Promise { + const response = await fetchWithStartupTimeout( + runtimeUrl.replace(/\/api\/.*$/, "/healthz"), + fetchImpl, + ); + return response?.ok === true; +} + +function objectRecord(value: unknown): Record | undefined { + return value && typeof value === "object" ? value as Record : undefined; +} + +export function datasourceIdFromRunDefaults(value: unknown): string | undefined { + const envelope = objectRecord(value); + const data = objectRecord(envelope?.success === true ? envelope.data : value); + const activeDatasourceId = data?.activeDatasourceId; + + if (typeof activeDatasourceId === "string" && activeDatasourceId.trim()) { + return activeDatasourceId; + } + + const enabledDatasourceIds = data?.enabledDatasourceIds; + if (Array.isArray(enabledDatasourceIds)) { + const firstEnabled = enabledDatasourceIds.find( + (item): item is string => typeof item === "string" && item.trim().length > 0, + ); + return firstEnabled; + } + + return undefined; +} + +export async function preflightDefaultDatasourceId( + baseUrl: string, + fetchImpl: typeof fetch, +): Promise { + const result = await fetchJsonWithStartupTimeout( + `${baseUrl.replace(/\/$/, "")}/api/v1/run-defaults`, + fetchImpl, + ); + if (!result?.ok) { + return undefined; + } + + try { + return datasourceIdFromRunDefaults(result.body); + } catch { + return undefined; + } +} + +export function configBaseUrlFromRuntime(runtimeUrl: string): string { + const apiIndex = runtimeUrl.indexOf("/api/"); + if (apiIndex >= 0) { + return runtimeUrl.slice(0, apiIndex); + } + return runtimeUrl.replace(/\/$/, ""); +} diff --git a/apps/tui/src/state/demo-state.ts b/apps/tui/src/state/demo-state.ts deleted file mode 100644 index c4fa2a19..00000000 --- a/apps/tui/src/state/demo-state.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { store } from "./store.js"; - -export function seedDemoState(datasourceId: string): void { - store.addUserMessage("Which regions are leading revenue this month?"); - store.addAssistantMessage( - "Demo data is loaded. I inspected the datasource schema, ran a mock read-only SQL query, and generated a small result artifact. You can keep typing in demo mode without starting the API server.", - false, - ); - - store.handleLiveRunEvent({ - type: "RUN_STARTED", - threadId: "demo-thread", - runId: "demo-run", - }); - store.handleLiveRunEvent({ - type: "ACTIVITY_SNAPSHOT", - activityType: "PLAN", - messageId: "demo-plan", - content: { - tasks: [ - { id: "inspect", title: "Inspect datasource schema", status: "completed" }, - { id: "query", title: "Run read-only SQL", status: "completed" }, - { id: "final", title: "Summarize results", status: "completed" }, - ], - }, - }); - store.handleLiveRunEvent({ - type: "TOOL_CALL_START", - toolCallId: "demo-schema", - toolCallName: "inspect_schema", - }); - store.handleLiveRunEvent({ - type: "TOOL_CALL_RESULT", - toolCallId: "demo-schema", - toolCallName: "inspect_schema", - content: JSON.stringify({ - tables: [ - { - name: "orders", - columns: [ - { name: "order_date", type: "DATE" }, - { name: "region", type: "TEXT" }, - { name: "revenue", type: "DOUBLE" }, - ], - }, - ], - }), - }); - store.handleLiveRunEvent({ - type: "TOOL_CALL_START", - toolCallId: "demo-sql", - toolCallName: "run_sql_readonly", - args: { - sql: "select region, sum(revenue) as revenue from orders group by region order by revenue desc limit 5", - }, - }); - store.handleLiveRunEvent({ - type: "CUSTOM", - name: "sql_audit", - value: { - audit_log_id: "demo-audit-1", - datasource_id: datasourceId, - status: "success", - row_count: 5, - elapsed_ms: 84, - }, - }); - store.handleLiveRunEvent({ - type: "TOOL_CALL_RESULT", - toolCallId: "demo-sql", - toolCallName: "run_sql_readonly", - content: JSON.stringify({ - row_count: 5, - elapsed_ms: 84, - }), - }); - store.handleLiveRunEvent({ - type: "CUSTOM", - name: "artifact", - value: { - id: "demo-top-regions", - type: "table", - title: "Top regions by revenue", - summary: "Demo table generated from a mock read-only SQL query.", - preview_json: { - columns: ["region", "revenue"], - rows: [ - ["East", "1,284,000"], - ["South", "973,500"], - ["North", "812,300"], - ], - }, - }, - }); - store.handleLiveRunEvent({ - type: "RUN_FINISHED", - threadId: "demo-thread", - runId: "demo-run", - outcome: { type: "success" }, - }); -} diff --git a/apps/tui/src/ui/App.tsx b/apps/tui/src/ui/App.tsx index 5832046b..a954671a 100644 --- a/apps/tui/src/ui/App.tsx +++ b/apps/tui/src/ui/App.tsx @@ -37,10 +37,13 @@ import { classifyError, formatErrorMessage, errorLogger } from '../protocol/erro import { commandProcessor } from '../commands/index.js'; import type { CommandContext, CommandResult } from '../commands/types.js'; import { ConfigClientError, type ConfigClient, type Datasource, type SessionListItem, type Skill } from '../config/index.js'; +import type { AppExitReason, AuthCommandController } from '../auth/types.js'; interface AppProps { client: AgentClient; configClient?: ConfigClient | undefined; + authController?: AuthCommandController | undefined; + onExit?: ((reason: AppExitReason) => void) | undefined; datasourceId: string | undefined; initialDatasourceId?: string | undefined; initialResume?: { @@ -318,6 +321,8 @@ const findSkillPickerItem = ( export const App: React.FC = ({ client, configClient, + authController, + onExit, datasourceId, initialDatasourceId, initialResume, @@ -333,6 +338,19 @@ export const App: React.FC = ({ const [state, setState] = useState(store.getState()); const [inputFocused, setInputFocused] = useState(false); const [commandNotice, setCommandNotice] = useState(null); + const [logoutConfirm, setLogoutConfirm] = useState< + | { + kind: "remote-failed"; + clearLocalOnly: () => Promise; + } + | { + kind: "local-cleanup-failed"; + storePath: string; + error: string; + retry: () => Promise; + } + | null + >(null); const [activeDatasourceId, setActiveDatasourceId] = useState( () => datasourceId ?? initialDatasourceId @@ -517,14 +535,48 @@ export const App: React.FC = ({ }, CTRL_EXIT_PROMPT_DURATION_MS); }, [clearCtrlCExitTimer]); - const exitApplication = useCallback(() => { + const exitApplication = useCallback((reason: AppExitReason = 'exit') => { clearCtrlCExitTimer(); setCtrlCPressedOnce(false); if ('dispose' in client && typeof client.dispose === 'function') { client.dispose(); } + onExit?.(reason); exit(); - }, [clearCtrlCExitTimer, client, exit]); + }, [clearCtrlCExitTimer, client, exit, onExit]); + + const handleLogoutAction = useCallback(async () => { + if (!authController) { + setCommandNotice({ + message: 'Logout is unavailable because no auth controller was provided.', + kind: 'error', + }); + return; + } + const result = await authController.logout(); + if (result.kind === 'complete') { + exitApplication('logout'); + return; + } + if (result.kind === 'local-cleanup-failed') { + setLogoutConfirm({ + kind: 'local-cleanup-failed', + storePath: result.storePath, + error: result.error, + retry: result.retry, + }); + setCommandNotice({ + message: `Signed out remotely, but local session cleanup failed: ${result.error}`, + kind: 'error', + }); + return; + } + setLogoutConfirm({ kind: 'remote-failed', clearLocalOnly: result.clearLocalOnly }); + setCommandNotice({ + message: 'Unable to reach the server. The remote session may still be valid.', + kind: 'error', + }); + }, [authController, exitApplication]); const clearQueuedPrompts = useCallback((): void => { queuedPromptsRef.current = []; @@ -1025,6 +1077,31 @@ export const App: React.FC = ({ // Handle global keyboard shortcuts useInput((input, key) => { + if (logoutConfirm) { + if (input === '1') { + const action = logoutConfirm.kind === 'remote-failed' + ? logoutConfirm.clearLocalOnly + : logoutConfirm.retry; + setLogoutConfirm(null); + void action() + .then(() => exitApplication('logout')) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + setCommandNotice({ + message: `Local session cleanup still failing: ${message}`, + kind: 'error', + }); + }); + return; + } + if (input === '2' || key.escape) { + setLogoutConfirm(null); + setCommandNotice(null); + return; + } + return; + } + if (key.ctrl && input === 'c') { if (pickerOpen || !inputFocused) { requestCtrlCExit(); @@ -1112,6 +1189,7 @@ export const App: React.FC = ({ const commandContext: CommandContext = { client, ...(configClient ? { configClient } : {}), + ...(authController ? { authController } : {}), ...(activeDatasourceId ? { datasourceId: activeDatasourceId } : {}), ...(activeSkillId ? { activeSkillId } : {}), workspaceConfig: currentState.workspaceConfig, @@ -1143,8 +1221,11 @@ export const App: React.FC = ({ clearQueuedPrompts(); store.startNewSession(createThreadId()); chatAreaRef.current?.reset(); + } else if (action === 'logout') { + await handleLogoutAction(); + return; } else if (action === 'exit_application') { - exitApplication(); + exitApplication('exit'); return; } else if (action === 'open_outputs') { setOutputsOpen(true); @@ -1542,6 +1623,27 @@ export const App: React.FC = ({ ? mainPaneColumns.chatColumns : terminalColumns; + if (logoutConfirm) { + if (logoutConfirm.kind === 'local-cleanup-failed') { + return ( + + 远端已注销,但本地 Session 清理失败。 + {`路径: ${logoutConfirm.storePath}`} + {`错误: ${logoutConfirm.error}`} + [1] 重试清理本地登录 + [2] 返回 + + ); + } + return ( + + 无法连接服务端,远端 Session 可能仍有效。 + [1] 仅清除此设备的登录 + [2] 返回 + + ); + } + return ( diff --git a/docs/en/capabilities.md b/docs/en/capabilities.md index 29e8b92e..2b158377 100644 --- a/docs/en/capabilities.md +++ b/docs/en/capabilities.md @@ -94,7 +94,7 @@ The TUI suits remote servers and terminal workflows: - `/datasource` to select a data source. - `/skill` to select a Skill. - `/resume` to restore server session history. -- `--demo` for local simulated event streams. +- Password sign-in with a local session cache (requires a running API). - `Tab` completion, input history, and Chat view scrolling. Registered commands are defined in [TUI guide](guides/tui.md). diff --git a/docs/en/guides/tui.md b/docs/en/guides/tui.md index 78be162a..b8b04623 100644 --- a/docs/en/guides/tui.md +++ b/docs/en/guides/tui.md @@ -40,10 +40,10 @@ Resume a specific thread/session: npm run start:tui -- --resume thread-001 ``` -Demo mode does not connect to the backend—useful for layout, commands, and simulated event streams: +The TUI requires a running API and password sign-in (offline demo mode was removed): ```bash -npm run start:tui -- --demo +npm run start:tui -- --runtime-url http://127.0.0.1:8787/api/copilotkit ``` View CLI flags: @@ -111,7 +111,7 @@ When connected to a real backend, the TUI sends natural-language input to `/api/ `/resume` depends on `/api/v1/sessions` and `/api/v1/sessions/:id/conversation`. If the backend is unavailable or sessions are unsupported, the TUI shows an error in the command hint area. -Demo mode uses local simulated events and built-in demo state. It does not call a real backend and cannot restore server sessions. +Offline demo mode has been removed. The TUI always requires a running API and password sign-in. ## Typical flow @@ -144,7 +144,7 @@ Use the Web workbench for full visual demos. Use the TUI to verify agent runtime - Cannot connect: confirm `npm run dev` or `npm run dev:api` is running. - Backend URL changed: pass full `/api/copilotkit` URL with `--runtime-url`. - Model not responding: check `LLM_PROVIDER`, `LLM_MODEL`, `LLM_BASE_URL`, and `LLM_API_KEY` in root `.env`. -- Session restore fails: confirm `/api/v1/sessions` is reachable and start without `--demo`. +- Session restore fails: confirm `/api/v1/sessions` is reachable and that you are signed in against the same `--runtime-url`. - Command has no effect: run `/help` and check errors in the command hint area. Continue with [Web workbench guide](web-workbench.md). diff --git a/docs/en/quick-start.md b/docs/en/quick-start.md index f7291af4..b4a7ff3d 100644 --- a/docs/en/quick-start.md +++ b/docs/en/quick-start.md @@ -232,10 +232,10 @@ Optionally point at the deployed API URL (defaults to `API_PORT` from `.env`): ./deploy.sh tui --runtime-url http://127.0.0.1:8787/api/copilotkit ``` -Demo mode without a backend: +Sign-in needs a running API and a password account (offline demo mode was removed): ```bash -npm run start:tui -- --demo +npm run start:tui -- --runtime-url http://127.0.0.1:8787/api/copilotkit ``` Resume the latest server session: diff --git a/docs/zh/capabilities.md b/docs/zh/capabilities.md index 9c1b0db2..b92fea95 100644 --- a/docs/zh/capabilities.md +++ b/docs/zh/capabilities.md @@ -94,7 +94,7 @@ TUI 适合远程服务器和终端工作流: - 支持 `/datasource` 选择数据源。 - 支持 `/skill` 选择 Skill。 - 支持 `/resume` 恢复服务端历史会话。 -- 支持 `--demo` 查看本地模拟事件流。 +- 支持密码登录与本地 Session 缓存(需连接运行中的 API)。 - 支持 `Tab` 命令补全、输入历史和 Chat 视图滚动。 当前注册命令以 [TUI 指南](guides/tui.md) 为准。 diff --git a/docs/zh/guides/tui.md b/docs/zh/guides/tui.md index d97c2bea..a3bf6269 100644 --- a/docs/zh/guides/tui.md +++ b/docs/zh/guides/tui.md @@ -40,10 +40,10 @@ npm run start:tui -- --resume npm run start:tui -- --resume thread-001 ``` -演示模式不连接后端,适合查看布局、命令系统和模拟事件流: +TUI 需要连接正在运行的 API,并用密码账户登录(离线演示模式已移除): ```bash -npm run start:tui -- --demo +npm run start:tui -- --runtime-url http://127.0.0.1:8787/api/copilotkit ``` 查看 CLI 参数: @@ -111,7 +111,7 @@ TUI 默认停留在 Chat。使用 `/outputs` 可以像 `/resume` 一样打开独 `/resume` 依赖 `/api/v1/sessions` 和 `/api/v1/sessions/:id/conversation`。后端不可用或服务端不支持会话接口时,TUI 会在命令提示区显示错误。 -演示模式使用本地模拟事件和内置 demo 状态。它不会调用真实后端,也不能恢复服务端会话。 +离线演示模式已移除。TUI 始终需要可用的 API 与密码账户登录。 ## 典型流程 diff --git a/docs/zh/quick-start.md b/docs/zh/quick-start.md index b693df57..39b01175 100644 --- a/docs/zh/quick-start.md +++ b/docs/zh/quick-start.md @@ -232,10 +232,10 @@ curl http://127.0.0.1:8787/ready # Mastra / builtin 就绪(含 startup_ms ./deploy.sh tui --runtime-url http://127.0.0.1:8787/api/copilotkit ``` -演示模式不需要后端: +登录需要可用的 API 与密码账户(离线演示模式已移除): ```bash -npm run start:tui -- --demo +npm run start:tui -- --runtime-url http://127.0.0.1:8787/api/copilotkit ``` 恢复最近的服务端会话: diff --git a/package.json b/package.json index dd5a380d..36cc030b 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "smoke:metadata": "npm run build && node scripts/smoke-metadata.mjs", "test:auth-foundation": "npm run build && node --test scripts/lib/authenticated-test-client.test.mjs scripts/auth-foundation.test.mjs", "smoke:auth": "npm run build && node scripts/smoke-auth.mjs", + "smoke:tui-auth-sharing": "npm run build && npm --workspace @datafoundry/tui run build && node scripts/smoke-tui-auth-sharing.mjs", "smoke:files": "npm run build && node scripts/smoke-files.mjs", "smoke:skills": "npm run build && node scripts/smoke-skills.mjs", "smoke:run-identity": "npm run build && node scripts/smoke-run-identity.mjs", diff --git a/scripts/auth-foundation.test.mjs b/scripts/auth-foundation.test.mjs index b1a38903..e39e3c49 100644 --- a/scripts/auth-foundation.test.mjs +++ b/scripts/auth-foundation.test.mjs @@ -30,6 +30,7 @@ const FORMAL_HTTP_AUTH_TARGETS = [ "smoke-agent-protocol-deepseek.mjs", "smoke-ask-user-interrupt.mjs", "smoke-auth.mjs", + "smoke-tui-auth-sharing.mjs", "smoke-config-api.mjs", "smoke-copilotkit-run.mjs", "smoke-copilotkit.mjs", diff --git a/scripts/smoke-tui-auth-sharing.mjs b/scripts/smoke-tui-auth-sharing.mjs new file mode 100644 index 00000000..eb170413 --- /dev/null +++ b/scripts/smoke-tui-auth-sharing.mjs @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { createServer as createApiServer } from "../apps/api/dist/server.js"; +import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; + +const tuiAuthUrl = pathToFileURL( + join(process.cwd(), "apps/tui/dist/auth/index.js") +).href; +const { + AuthenticatedTransport, + TuiAuthClient, + TuiCookieJar, +} = await import(tuiAuthUrl); + +const root = mkdtempSync(join(tmpdir(), "datafoundry-tui-auth-share-")); +process.env.DATAFOUNDRY_AUTH_MODE = "password"; +process.env.AUTH_SESSION_SECRET = "tui-share-session-secret-with-32-bytes!"; +process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1"; +process.env.AUTH_EMAIL_DELIVERY = "test"; +process.env.AUTH_REGISTRATION_MODE = "open"; +process.env.EMBEDDING_API_KEY = ""; +process.env.MASTRA_STORAGE_PATH = join(root, "mastra.sqlite"); +process.env.STORAGE_ROOT_DIR = root; + +const metadataStore = createMetadataStore({ + database_path: join(root, "metadata.sqlite"), + secret_master_key: "tui-share-secret-master-key", +}); +const server = await createApiServer({ metadataStore }); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +assert(address && typeof address === "object"); +const baseUrl = `http://127.0.0.1:${address.port}`; + +const closeServer = async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + setImmediate(() => server.closeAllConnections?.()); + }); +}; + +try { + const web = createAuthenticatedTestClient({ baseUrl }); + const webIdentity = await web.registerAndLogin({ + email: "share@example.com", + password: "correct horse battery staple", + displayName: "Share User", + client: "web", + }); + + const webSessionId = "web-created-session"; + const webCreate = await web.fetchJson(`/api/v1/sessions/${webSessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Created by Web" }), + }); + assert.equal(webCreate.response.status, 200, JSON.stringify(webCreate.body)); + + const tuiJar = new TuiCookieJar(); + const tuiAuth = new TuiAuthClient({ + apiBaseUrl: baseUrl, + cookieJar: tuiJar, + }); + const tuiSession = await tuiAuth.login("share@example.com", "correct horse battery staple"); + assert.equal(tuiSession.user.id, webIdentity.userId); + assert.equal(tuiSession.workspace.id, webIdentity.workspaceId); + assert.ok(tuiSession.expiresAt); + + const transport = new AuthenticatedTransport({ + cookieJar: tuiJar, + refreshCsrf: () => tuiAuth.refreshCsrf(), + onSessionInvalid: async () => { + tuiJar.clear(); + }, + }); + + const meResponse = await transport.fetch(`${baseUrl}/api/v1/me`); + const meBody = await meResponse.json(); + assert.equal(meResponse.status, 200); + assert.equal(meBody.data.user.id, webIdentity.userId); + assert.equal(meBody.data.workspace.id, webIdentity.workspaceId); + + const resumeList = await transport.fetch(`${baseUrl}/api/v1/sessions?limit=20`); + const resumeBody = await resumeList.json(); + assert.equal(resumeList.status, 200, JSON.stringify(resumeBody)); + assert.ok( + resumeBody.data.sessions.some((session) => session.id === webSessionId), + "TUI should see the Web-created session", + ); + + const conversation = await transport.fetch( + `${baseUrl}/api/v1/sessions/${webSessionId}/conversation`, + ); + assert.equal(conversation.status, 200); + + const tuiSessionId = "tui-created-session"; + const tuiCreate = await transport.fetch(`${baseUrl}/api/v1/sessions/${tuiSessionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: "Created by TUI" }), + }); + const tuiCreateBody = await tuiCreate.json(); + assert.equal(tuiCreate.status, 200, JSON.stringify(tuiCreateBody)); + + const webList = await web.fetchJson("/api/v1/sessions?limit=20"); + assert.equal(webList.response.status, 200); + assert.ok( + webList.body.data.sessions.some((session) => session.id === tuiSessionId), + "Web should see the TUI-created session", + ); + + const anonymousAgui = await fetch(`${baseUrl}/api/copilotkit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + assert.equal(anonymousAgui.status, 401); + + const authenticatedAgui = await transport.fetch(`${baseUrl}/api/copilotkit`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({}), + }); + const aguiBody = await authenticatedAgui.json(); + const acceptedAuth = + (authenticatedAgui.status === 503 && aguiBody?.error?.code === "PROVIDER_CONFIG_MISSING") + || (authenticatedAgui.status === 400 && aguiBody?.message === "Missing method field") + || authenticatedAgui.status !== 401; + assert.ok( + acceptedAuth, + `AG-UI should accept authenticated Cookie/CSRF, got ${authenticatedAgui.status} ${JSON.stringify(aguiBody)}`, + ); + assert.notEqual(authenticatedAgui.status, 401); + + console.log("TUI/Web auth sharing smoke OK"); +} finally { + await closeServer(); +} From 76d4666b4d8d51b8000f9d5f71e4ad487555dc93 Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Sun, 26 Jul 2026 16:10:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(tui):=20=E5=85=B3=E9=97=AD=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B8=85=E7=90=86=E4=B8=8E=20Cookie=20=E5=90=8A?= =?UTF-8?q?=E9=94=80=E7=9A=84=E7=99=BB=E5=BD=95=E6=81=A2=E5=A4=8D=E6=BC=8F?= =?UTF-8?q?=E6=B4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Max-Age=0/过期 Expires 的 Set-Cookie 现在会删除 jar 条目;session-invalid 清理失败仍通知 auth-required;logout/auth-required 后强制交互登录,避免 磁盘残留会话被静默恢复。 --- .../src/auth/authenticated-transport.test.ts | 28 ++++++ apps/tui/src/auth/authenticated-transport.ts | 7 +- apps/tui/src/auth/bootstrap.ts | 7 +- apps/tui/src/auth/cookie-jar.test.ts | 25 ++++++ apps/tui/src/auth/cookie-jar.ts | 33 ++++++- apps/tui/src/index-auth-wiring.test.ts | 88 +++++++++++++++++++ apps/tui/src/index.tsx | 8 +- 7 files changed, 190 insertions(+), 6 deletions(-) diff --git a/apps/tui/src/auth/authenticated-transport.test.ts b/apps/tui/src/auth/authenticated-transport.test.ts index 4c95bdb6..3ac6db9c 100644 --- a/apps/tui/src/auth/authenticated-transport.test.ts +++ b/apps/tui/src/auth/authenticated-transport.test.ts @@ -274,6 +274,34 @@ describe("AuthenticatedTransport", () => { assert.equal(invalidCalls, 1); }); + it("still notifies auth-required when onSessionInvalid cleanup throws", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "sess", df_csrf: "csrf" }); + let authRequired = 0; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + throw new Error("disk locked"); + }, + fetchImpl: async () => jsonResponse(401, { error: { code: "UNAUTHORIZED" } }), + }); + transport.onAuthRequired(() => { + authRequired += 1; + }); + + const response = await transport.fetch("http://127.0.0.1/api/v1/me"); + assert.equal(response.status, 401); + assert.equal(authRequired, 1); + + // Sticky replay for late subscribers must still work after a failed cleanup. + let late = 0; + transport.onAuthRequired(() => { + late += 1; + }); + assert.equal(late, 1); + }); + it("dedups concurrent 401 cleanup before onSessionInvalid side effects", async () => { const jar = new TuiCookieJar(); jar.replace({ df_session: "sess", df_csrf: "csrf" }); diff --git a/apps/tui/src/auth/authenticated-transport.ts b/apps/tui/src/auth/authenticated-transport.ts index 61dd1066..6c49f860 100644 --- a/apps/tui/src/auth/authenticated-transport.ts +++ b/apps/tui/src/auth/authenticated-transport.ts @@ -101,7 +101,12 @@ export class AuthenticatedTransport { } private async runSessionInvalid(): Promise { - await this.onSessionInvalid(); + try { + await this.onSessionInvalid(); + } catch { + // Disk/cleanup failures must not block auth-required UX; jar clear is best-effort + // inside onSessionInvalid. Sticky notification still proceeds below. + } for (const listener of this.authRequiredListeners) { try { listener(); diff --git a/apps/tui/src/auth/bootstrap.ts b/apps/tui/src/auth/bootstrap.ts index b002f31e..a8092b4a 100644 --- a/apps/tui/src/auth/bootstrap.ts +++ b/apps/tui/src/auth/bootstrap.ts @@ -194,7 +194,12 @@ export function createTransport(options: { refreshCsrf: () => options.authClient.refreshCsrf(), onSessionInvalid: async () => { options.cookieJar.clear(); - await options.sessionStore.remove(apiBaseUrl); + try { + await options.sessionStore.remove(apiBaseUrl); + } catch { + // Prefer clearing the in-memory jar and surfacing auth-required over crashing + // the transport when the on-disk store is locked or unwritable. + } }, }); } diff --git a/apps/tui/src/auth/cookie-jar.test.ts b/apps/tui/src/auth/cookie-jar.test.ts index 71aad670..245785e7 100644 --- a/apps/tui/src/auth/cookie-jar.test.ts +++ b/apps/tui/src/auth/cookie-jar.test.ts @@ -59,4 +59,29 @@ describe("TuiCookieJar", () => { assert.equal(jar.headerValue(), undefined); assert.equal(jar.csrfToken(), undefined); }); + + it("deletes cookies when Set-Cookie uses Max-Age=0 (logout/clear)", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "live", df_csrf: "csrf-live", keep: "yes" }); + const headers = new Headers(); + headers.append("set-cookie", "df_session=; Path=/; HttpOnly; Max-Age=0"); + headers.append("set-cookie", "df_csrf=; Path=/; Max-Age=0"); + jar.absorbSetCookie(headers); + + assert.deepEqual(jar.snapshot(), { keep: "yes" }); + assert.equal(jar.csrfToken(), undefined); + assert.equal(jar.headerValue(), "keep=yes"); + }); + + it("deletes cookies when Expires is in the past", () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "live" }); + const headers = new Headers(); + headers.append( + "set-cookie", + "df_session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT", + ); + jar.absorbSetCookie(headers); + assert.deepEqual(jar.snapshot(), {}); + }); }); diff --git a/apps/tui/src/auth/cookie-jar.ts b/apps/tui/src/auth/cookie-jar.ts index 5ae83dba..b4de4f85 100644 --- a/apps/tui/src/auth/cookie-jar.ts +++ b/apps/tui/src/auth/cookie-jar.ts @@ -20,7 +20,8 @@ export class TuiCookieJar { : splitSetCookieHeader(headers.get("set-cookie")); for (const cookie of values) { - const pair = String(cookie).split(";", 1)[0] ?? ""; + const segments = String(cookie).split(";"); + const pair = segments[0] ?? ""; const eq = pair.indexOf("="); if (eq <= 0) { continue; @@ -29,6 +30,10 @@ export class TuiCookieJar { if (!name) { continue; } + if (shouldDeleteCookie(segments.slice(1))) { + delete this.store[name]; + continue; + } const rawValue = pair.slice(eq + 1); try { this.store[name] = decodeURIComponent(rawValue); @@ -67,3 +72,29 @@ function splitSetCookieHeader(value: string | null): string[] { } return [value]; } + +/** Honor Max-Age=0 / past Expires so logout Set-Cookie actually clears the jar. */ +function shouldDeleteCookie(attributeSegments: string[]): boolean { + for (const segment of attributeSegments) { + const trimmed = segment.trim(); + if (!trimmed) { + continue; + } + const eq = trimmed.indexOf("="); + const attrName = (eq >= 0 ? trimmed.slice(0, eq) : trimmed).trim().toLowerCase(); + const attrValue = eq >= 0 ? trimmed.slice(eq + 1).trim() : ""; + if (attrName === "max-age") { + const maxAge = Number(attrValue); + if (Number.isFinite(maxAge) && maxAge <= 0) { + return true; + } + } + if (attrName === "expires") { + const expiresMs = Date.parse(attrValue); + if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) { + return true; + } + } + } + return false; +} diff --git a/apps/tui/src/index-auth-wiring.test.ts b/apps/tui/src/index-auth-wiring.test.ts index 30f85434..5e17a567 100644 --- a/apps/tui/src/index-auth-wiring.test.ts +++ b/apps/tui/src/index-auth-wiring.test.ts @@ -372,6 +372,94 @@ describe("runTui auth wiring", () => { assert.equal(appStarts, 2); }); + it("forces interactive login after auth-required even if a disk session remains", async () => { + const dir = await mkdtemp(join(tmpdir(), "tui-force-login-")); + const sessionStore = new TuiSessionStore({ filePath: join(dir, "auth.json") }); + await sessionStore.save({ + apiBaseUrl: "http://127.0.0.1:8787", + cookies: { df_session: "stale", df_csrf: "stale-csrf" }, + user: { id: "u0", email: "stale@example.com" }, + workspace: { id: "w0" }, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + + const answers = ["1", "fresh@example.com", "pw"]; + let idx = 0; + let meCalls = 0; + let loginCalls = 0; + let appStarts = 0; + + const code = await runTui({ + argv: ["--runtime-url", "http://127.0.0.1:8787/api/copilotkit"], + sessionStore, + fetchImpl: async (input) => { + const url = String(input); + if (url.includes("/api/v1/auth/status")) { + return json(200, { + success: true, + data: { publicBaseUrl: "http://127.0.0.1:3000", registrationEnabled: false }, + }); + } + if (url.includes("/api/v1/me")) { + meCalls += 1; + return json(200, { + success: true, + data: { + user: { id: "u0", email: "stale@example.com" }, + workspace: { id: "w0" }, + }, + }); + } + if (url.includes("/api/v1/auth/login")) { + loginCalls += 1; + return json( + 200, + { + success: true, + data: { + user: { id: "u1", email: "fresh@example.com" }, + workspace: { id: "w1" }, + session: { expiresAt: "2099-01-01T00:00:00.000Z" }, + }, + }, + ["df_session=fresh; Path=/", "df_csrf=fresh-csrf; Path=/"], + ); + } + if (url.includes("/healthz")) { + return new Response("ok", { status: 200 }); + } + if (url.includes("/api/v1/run-defaults")) { + return json(200, { success: true, data: {} }); + } + return json(404, { success: false, error: { code: "NOT_FOUND", message: "x" } }); + }, + prompt: { + question: async () => answers[idx++] ?? "3", + password: async () => answers[idx++] ?? "", + close: () => {}, + }, + stdout: { write: () => true } as unknown as NodeJS.WritableStream, + renderApp: async () => { + appStarts += 1; + // Simulate cleanup failing to delete the on-disk session before re-entry. + await sessionStore.save({ + apiBaseUrl: "http://127.0.0.1:8787", + cookies: { df_session: "stale", df_csrf: "stale-csrf" }, + user: { id: "u0", email: "stale@example.com" }, + workspace: { id: "w0" }, + expiresAt: "2099-01-01T00:00:00.000Z", + }); + return appStarts === 1 ? "auth-required" : "exit"; + }, + }); + + assert.equal(code, 0); + assert.equal(appStarts, 2); + assert.equal(loginCalls, 1, "must prompt interactive login instead of restoring disk session"); + // First bootstrap may call /me once; re-entry after auth-required must not silently restore. + assert.ok(meCalls <= 1); + }); + it("rejects removed offline demo mode", async () => { const code = await runTui({ argv: [`--${"demo"}`] }); assert.equal(code, 1); diff --git a/apps/tui/src/index.tsx b/apps/tui/src/index.tsx index 55a8c3ce..0d7756e8 100644 --- a/apps/tui/src/index.tsx +++ b/apps/tui/src/index.tsx @@ -197,7 +197,8 @@ export async function runTui(options: RunTuiOptions = {}): Promise { let configBaseUrl = configBaseUrlFromRuntime(runtimeUrl); const explicitDatasourceId = getOptionalArg(args, "--datasource-id"); const agent = getArg(args, "--agent", "dataFoundry"); - const noAutoLogin = args.includes("--no-auto-login"); + const cliNoAutoLogin = args.includes("--no-auto-login"); + let forceInteractiveLogin = cliNoAutoLogin; const initialResume = resolveResumeRequest(args); const stdout = options.stdout ?? process.stdout; @@ -208,7 +209,7 @@ export async function runTui(options: RunTuiOptions = {}): Promise { try { bootstrap = await bootstrapTuiAuth({ apiBaseUrl: configBaseUrl, - noAutoLogin, + noAutoLogin: forceInteractiveLogin, fetchImpl, ...(options.sessionStore ? { sessionStore: options.sessionStore } : {}), }); @@ -344,7 +345,8 @@ export async function runTui(options: RunTuiOptions = {}): Promise { if (exitReason === "auth-required") { console.log("Session expired or revoked. Please sign in again."); } - // Re-enter auth menu; force interactive login for account switching clarity. + // Re-enter auth menu; never silently restore a possibly-stale disk session. + forceInteractiveLogin = true; continue; } return 0; From 0ffc70b2e62aa25d08d517bc3dfc8578386bf982 Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Tue, 28 Jul 2026 11:08:03 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(tui):=20=E7=A6=81=E6=AD=A2=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E4=BC=A0=E8=BE=93=E8=87=AA=E5=8A=A8=E8=B7=9F=E9=9A=8F?= =?UTF-8?q?=E9=87=8D=E5=AE=9A=E5=90=91=E6=B1=A1=E6=9F=93=20Cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuthenticatedTransport 默认 redirect:follow 时,开放重定向目标的 Set-Cookie 会写入 jar 并覆盖 df_session。改为 redirect:manual,与 auth-client 一致,避免跨 origin 响应毒化会话。 --- .../src/auth/authenticated-transport.test.ts | 31 +++++++++++++++++++ apps/tui/src/auth/authenticated-transport.ts | 6 ++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/apps/tui/src/auth/authenticated-transport.test.ts b/apps/tui/src/auth/authenticated-transport.test.ts index 3ac6db9c..73a36fa7 100644 --- a/apps/tui/src/auth/authenticated-transport.test.ts +++ b/apps/tui/src/auth/authenticated-transport.test.ts @@ -419,4 +419,35 @@ describe("AuthenticatedTransport", () => { }, ); }); + + it("uses redirect:manual so open redirects cannot poison the cookie jar", async () => { + const jar = new TuiCookieJar(); + jar.replace({ df_session: "good", df_csrf: "csrf" }); + let seenRedirect: RequestRedirect | undefined; + const transport = new AuthenticatedTransport({ + cookieJar: jar, + refreshCsrf: async () => {}, + onSessionInvalid: async () => { + throw new Error("redirect must not invalidate the session"); + }, + fetchImpl: async (_input, init) => { + seenRedirect = init?.redirect; + // With redirect:manual the client must surface the hop, never follow to evil. + return new Response(null, { + status: 302, + headers: { + Location: "http://127.0.0.1:9/evil", + "Set-Cookie": "df_session=POISONED; Path=/", + }, + }); + }, + }); + + const response = await transport.fetch("http://127.0.0.1:8787/api/v1/me"); + assert.equal(seenRedirect, "manual"); + assert.equal(response.status, 302); + // Same-origin 302 Set-Cookie from the API is still absorbed; the security win is + // never fetching the Location target (whose Set-Cookie would otherwise replace df_session). + assert.equal(jar.snapshot().df_session, "POISONED"); + }); }); diff --git a/apps/tui/src/auth/authenticated-transport.ts b/apps/tui/src/auth/authenticated-transport.ts index 6c49f860..33d87a73 100644 --- a/apps/tui/src/auth/authenticated-transport.ts +++ b/apps/tui/src/auth/authenticated-transport.ts @@ -130,16 +130,18 @@ export class AuthenticatedTransport { input: string | URL | Request, init?: RequestInit, ): Promise { + // Never auto-follow redirects: a hop to another origin can return Set-Cookie + // that would overwrite df_session/df_csrf in the jar (open-redirect poison). if (input instanceof Request) { const headers = new Headers(input.headers); applyAuthHeaders(headers, input.method, this.cookieJar); - return this.fetchImpl(new Request(input, { headers })); + return this.fetchImpl(new Request(input, { headers, redirect: "manual" })); } const headers = new Headers(init?.headers); const method = String(init?.method ?? "GET"); applyAuthHeaders(headers, method, this.cookieJar); - return this.fetchImpl(input, { ...init, headers }); + return this.fetchImpl(input, { ...init, headers, redirect: "manual" }); } }