From 28596749caca7c167e438e6d20ea9c24611a838d Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Fri, 24 Jul 2026 10:51:08 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=20M0A.5a?= =?UTF-8?q?=20=E6=AD=A3=E5=BC=8F=E8=AE=A4=E8=AF=81=E5=9F=BA=E7=A1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现 password 模式会话/CSRF/注册策略,并修复 HITL 事件桥、cookie Path 与缺失注册策略静默 open 等确定性缺陷。脚本侧统一正式认证客户端,并为 FORMAL_HTTP_AUTH_TARGETS 增加 node --check 语法门禁。 基于 main reset 后重提原 #83,叠在 DataLink 外置的一键部署分支之上。 Co-authored-by: wing --- .env.example | 6 +- .github/workflows/ci.yml | 9 + apps/api/src/auth/config.ts | 101 ++- apps/api/src/auth/cookies.ts | 67 +- apps/api/src/auth/routes.ts | 71 +- apps/api/src/auth/service.ts | 71 +- .../src/interaction-runtime-adapter.test.ts | 110 +++ apps/api/src/interaction-runtime-adapter.ts | 51 ++ apps/api/src/server.ts | 47 +- apps/web/.env.example | 6 +- package.json | 1 + .../src/tools/governed-tool-factory.test.ts | 59 ++ .../src/tools/governed-tool-factory.ts | 7 +- packages/contracts/src/index.ts | 2 + packages/metadata/src/index.ts | 12 + scripts/auth-foundation.test.mjs | 659 ++++++++++++++++++ scripts/lib/authenticated-test-client.mjs | 222 ++++++ .../lib/authenticated-test-client.test.mjs | 220 ++++++ scripts/run-dacomp6-complex-case.mjs | 12 +- scripts/seed-dtc-growth-demo.mjs | 13 +- scripts/seed-local-fixtures.mjs | 15 +- scripts/smoke-agent-protocol-deepseek.mjs | 17 +- scripts/smoke-ask-user-interrupt.mjs | 5 +- scripts/smoke-auth.mjs | 43 +- scripts/smoke-config-api.mjs | 204 +++--- scripts/smoke-copilotkit-run.mjs | 65 +- scripts/smoke-copilotkit.mjs | 14 +- scripts/smoke-interaction-run-id.mjs | 5 +- scripts/smoke-password-frontend-isolation.mjs | 125 +--- scripts/smoke-server-datasources-e2e.mjs | 16 +- .../test-builtin-dtc-growth-datasource.mjs | 21 +- scripts/verify-token-usage-display.mjs | 5 +- 32 files changed, 1961 insertions(+), 320 deletions(-) create mode 100644 apps/api/src/interaction-runtime-adapter.test.ts create mode 100644 scripts/auth-foundation.test.mjs create mode 100644 scripts/lib/authenticated-test-client.mjs create mode 100644 scripts/lib/authenticated-test-client.test.mjs diff --git a/.env.example b/.env.example index 593c7577..26f14303 100644 --- a/.env.example +++ b/.env.example @@ -76,7 +76,11 @@ SECRET_MASTER_KEY=replace-with-a-long-random-local-key DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 -# test = log verification/reset links to console; smtp = send real email. +# Required in password mode (open|closed). open = self-register; closed = REGISTRATION_CLOSED. +# Exposed via GET /api/v1/auth/status (no secrets). Do not use AUTH_EMAIL_DELIVERY=test +# with a non-loopback AUTH_PUBLIC_BASE_URL. +AUTH_REGISTRATION_MODE=open +# Required when set: only smtp|test. test = log links to console; smtp = send real email. AUTH_EMAIL_DELIVERY=test AUTH_EMAIL_FROM=DataFoundry AUTH_SMTP_HOST=smtp.example.com diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9da75ed2..b8ea18d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,12 @@ jobs: name: Core Smoke Tests runs-on: ubuntu-latest timeout-minutes: 25 + env: + DATAFOUNDRY_AUTH_MODE: password + AUTH_PUBLIC_BASE_URL: http://127.0.0.1:3000 + AUTH_REGISTRATION_MODE: open + AUTH_EMAIL_DELIVERY: test + AUTH_SESSION_SECRET: ci-auth-session-secret-with-32-bytes!! steps: - name: Checkout uses: actions/checkout@v5 @@ -65,6 +71,9 @@ jobs: - name: Build TypeScript workspaces run: npm run build + - name: Run formal auth foundation tests + run: npm run test:auth-foundation + - name: Generate built-in demo fixture env: SKIP_DTC_GROWTH_REGISTER: "1" diff --git a/apps/api/src/auth/config.ts b/apps/api/src/auth/config.ts index 00259a8d..ecdccf5e 100644 --- a/apps/api/src/auth/config.ts +++ b/apps/api/src/auth/config.ts @@ -1,8 +1,12 @@ export type AuthMode = "dev" | "password"; +export type RegistrationMode = "open" | "closed"; export type PasswordAuthConfig = { mode: AuthMode; publicBaseUrl: string; + registrationMode: RegistrationMode; + cookiePath: string; + cookieSecure: boolean; sessionSecret: string; emailDelivery: "smtp" | "test"; smtp?: { @@ -15,13 +19,65 @@ export type PasswordAuthConfig = { }; }; +export function validateAuthPublicUrl(raw: string): { + publicBaseUrl: string; + loopback: boolean; + cookiePath: string; + cookieSecure: boolean; +} { + let url: URL; + try { + url = new URL(raw); + } catch { + throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must be a valid absolute URL."); + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must use http or https."); + } + if (url.username || url.password) { + throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must not include credentials."); + } + if (url.hash) { + throw new Error("AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL must not include a fragment."); + } + + const host = url.hostname.toLowerCase().replace(/^\[(.*)\]$/, "$1"); + const loopback = host === "localhost" || host === "127.0.0.1" || host === "::1"; + if (url.protocol === "http:" && !loopback) { + throw new Error( + "AUTH_CONFIG_INVALID:AUTH_PUBLIC_BASE_URL HTTP is only allowed for loopback hosts (localhost, 127.0.0.1, ::1); use HTTPS for other hosts." + ); + } + + const normalized = new URL(url.href); + normalized.hash = ""; + normalized.search = ""; + // Keep pathname (including deployment prefix); strip only a trailing slash on root-equivalent leaves. + if (normalized.pathname.length > 1) { + normalized.pathname = normalized.pathname.replace(/\/+$/, ""); + } + + const cookiePath = normalized.pathname === "/" ? "/" : normalized.pathname; + + return { + publicBaseUrl: normalized.origin + (normalized.pathname === "/" ? "" : normalized.pathname), + loopback, + cookiePath, + cookieSecure: url.protocol === "https:" + }; +} + export function loadPasswordAuthConfig(env: Record): PasswordAuthConfig { const mode = parseAuthMode(env.DATAFOUNDRY_AUTH_MODE, env.NODE_ENV); const config: PasswordAuthConfig = { mode, publicBaseUrl: env.AUTH_PUBLIC_BASE_URL ?? "", + registrationMode: parseRegistrationMode(env.AUTH_REGISTRATION_MODE, mode === "password"), + cookiePath: "/", + cookieSecure: false, sessionSecret: env.AUTH_SESSION_SECRET ?? "", - emailDelivery: env.AUTH_EMAIL_DELIVERY === "test" ? "test" : "smtp" + emailDelivery: parseEmailDelivery(env.AUTH_EMAIL_DELIVERY) }; if (env.SMTP_HOST || env.AUTH_SMTP_HOST) { config.smtp = { @@ -37,6 +93,16 @@ export function loadPasswordAuthConfig(env: Record): } if (mode === "password") { validatePasswordAuthConfig(config); + } else if (config.publicBaseUrl) { + // Dev mode may still set a public URL; validate lightly when present. + try { + const validated = validateAuthPublicUrl(config.publicBaseUrl); + config.publicBaseUrl = validated.publicBaseUrl; + config.cookiePath = validated.cookiePath; + config.cookieSecure = validated.cookieSecure; + } catch { + // Keep legacy dev startups tolerant when AUTH_PUBLIC_BASE_URL is unused. + } } return config; } @@ -48,6 +114,29 @@ function parseAuthMode(value: string | undefined, nodeEnv: string | undefined): return nodeEnv === "production" ? "password" : "dev"; } +function parseRegistrationMode(value: string | undefined, required: boolean): RegistrationMode { + if (value === undefined || value.trim() === "") { + if (required) { + throw new Error("AUTH_CONFIG_MISSING:AUTH_REGISTRATION_MODE is required in password mode."); + } + return "open"; + } + if (value === "open" || value === "closed") { + return value; + } + throw new Error("AUTH_CONFIG_INVALID:AUTH_REGISTRATION_MODE must be open or closed."); +} + +function parseEmailDelivery(value: string | undefined): "smtp" | "test" { + if (value === undefined || value.trim() === "") { + return "smtp"; + } + if (value === "smtp" || value === "test") { + return value; + } + throw new Error("AUTH_CONFIG_INVALID:AUTH_EMAIL_DELIVERY must be smtp or test."); +} + function validatePasswordAuthConfig(config: PasswordAuthConfig): void { if (config.sessionSecret.length < 32) { throw new Error("AUTH_CONFIG_MISSING:AUTH_SESSION_SECRET must be at least 32 characters."); @@ -55,6 +144,16 @@ function validatePasswordAuthConfig(config: PasswordAuthConfig): void { if (!config.publicBaseUrl) { throw new Error("AUTH_CONFIG_MISSING:AUTH_PUBLIC_BASE_URL is required."); } + const validated = validateAuthPublicUrl(config.publicBaseUrl); + config.publicBaseUrl = validated.publicBaseUrl; + config.cookiePath = validated.cookiePath; + config.cookieSecure = validated.cookieSecure; + + if (config.emailDelivery === "test" && !validated.loopback) { + throw new Error( + "AUTH_CONFIG_INVALID:AUTH_EMAIL_DELIVERY=test is only allowed with a loopback AUTH_PUBLIC_BASE_URL." + ); + } if (config.emailDelivery === "smtp") { if (!config.smtp?.host || !config.smtp.from) { throw new Error("AUTH_CONFIG_MISSING:SMTP host and from address are required."); diff --git a/apps/api/src/auth/cookies.ts b/apps/api/src/auth/cookies.ts index 81623500..3c2b38e1 100644 --- a/apps/api/src/auth/cookies.ts +++ b/apps/api/src/auth/cookies.ts @@ -3,6 +3,11 @@ import type { IncomingMessage, ServerResponse } from "node:http"; export const SESSION_COOKIE = "df_session"; export const CSRF_COOKIE = "df_csrf"; +export type CookieSecurityOptions = { + path: string; + secure: boolean; +}; + export function parseCookies(request: IncomingMessage): Record { const header = request.headers.cookie; if (!header) { @@ -19,21 +24,53 @@ export function parseCookies(request: IncomingMessage): Record { export function appendAuthCookies(response: ServerResponse, input: { csrfToken: string; maxAgeSeconds: number; + path: string; sessionToken: string; + secure: boolean; }): void { appendSetCookie(response, serializeCookie(SESSION_COOKIE, input.sessionToken, { httpOnly: true, - maxAgeSeconds: input.maxAgeSeconds + maxAgeSeconds: input.maxAgeSeconds, + path: input.path, + secure: input.secure })); appendSetCookie(response, serializeCookie(CSRF_COOKIE, input.csrfToken, { httpOnly: false, - maxAgeSeconds: input.maxAgeSeconds + maxAgeSeconds: input.maxAgeSeconds, + path: input.path, + secure: input.secure + })); +} + +export function appendCsrfCookie( + response: ServerResponse, + value: string, + input: { path: string; secure: boolean; maxAgeSeconds: number } +): void { + appendSetCookie(response, serializeCookie(CSRF_COOKIE, value, { + httpOnly: false, + maxAgeSeconds: input.maxAgeSeconds, + path: input.path, + secure: input.secure })); } -export function appendClearAuthCookies(response: ServerResponse): void { - appendSetCookie(response, serializeCookie(SESSION_COOKIE, "", { httpOnly: true, maxAgeSeconds: 0 })); - appendSetCookie(response, serializeCookie(CSRF_COOKIE, "", { httpOnly: false, maxAgeSeconds: 0 })); +export function appendClearAuthCookies( + response: ServerResponse, + options: CookieSecurityOptions +): void { + appendSetCookie(response, serializeCookie(SESSION_COOKIE, "", { + httpOnly: true, + maxAgeSeconds: 0, + path: options.path, + secure: options.secure + })); + appendSetCookie(response, serializeCookie(CSRF_COOKIE, "", { + httpOnly: false, + maxAgeSeconds: 0, + path: options.path, + secure: options.secure + })); } function appendSetCookie(response: ServerResponse, cookie: string): void { @@ -46,14 +83,26 @@ function appendSetCookie(response: ServerResponse, cookie: string): void { response.setHeader("Set-Cookie", cookies); } -function serializeCookie(name: string, value: string, input: { httpOnly: boolean; maxAgeSeconds: number }): string { - const secure = process.env.NODE_ENV === "production"; +function serializeCookie( + name: string, + value: string, + input: { httpOnly: boolean; maxAgeSeconds: number; path: string; secure: boolean } +): string { + const path = normalizeCookiePath(input.path); return [ `${name}=${encodeURIComponent(value)}`, - "Path=/", + `Path=${path}`, `Max-Age=${input.maxAgeSeconds}`, "SameSite=Lax", - ...(secure ? ["Secure"] : []), + ...(input.secure ? ["Secure"] : []), ...(input.httpOnly ? ["HttpOnly"] : []) ].join("; "); } + +function normalizeCookiePath(path: string): string { + if (!path || path === "/") { + return "/"; + } + const trimmed = path.replace(/\/+$/u, ""); + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} diff --git a/apps/api/src/auth/routes.ts b/apps/api/src/auth/routes.ts index f4bb3ac5..dded1ac9 100644 --- a/apps/api/src/auth/routes.ts +++ b/apps/api/src/auth/routes.ts @@ -2,10 +2,19 @@ import { createErrorResult, createSuccessResult, type AppErrorCode } from "@data import type { IncomingMessage, ServerResponse } from "node:http"; import type { AuthIdentity, AuthService } from "./service.js"; import { AuthError, userDto, workspaceDto } from "./service.js"; -import { appendAuthCookies, appendClearAuthCookies, CSRF_COOKIE, parseCookies, SESSION_COOKIE } from "./cookies.js"; +import { + appendAuthCookies, + appendClearAuthCookies, + appendCsrfCookie, + CSRF_COOKIE, + parseCookies, + SESSION_COOKIE +} from "./cookies.js"; export type AuthRouteContext = { authService: AuthService; + cookiePath: string; + cookieSecure: boolean; identity?: AuthIdentity; }; @@ -25,6 +34,10 @@ export async function handleAuthApiRequest( ? {} : await readJsonBody(request); + if (root === "status" && request.method === "GET") { + sendJson(response, 200, createSuccessResult(context.authService.getPublicStatus())); + return true; + } if (root === "register" && request.method === "POST") { const result = await context.authService.register({ email: requiredString(body.email, "email"), @@ -39,16 +52,22 @@ export async function handleAuthApiRequest( const result = await context.authService.login({ email: requiredString(body.email, "email"), password: requiredString(body.password, "password"), + client: optionalClient(body.client), ...requestMeta(request) }); appendAuthCookies(response, { csrfToken: result.csrfToken, maxAgeSeconds: result.maxAgeSeconds, - sessionToken: result.sessionToken + path: context.cookiePath, + sessionToken: result.sessionToken, + secure: context.cookieSecure }); sendJson(response, 200, createSuccessResult({ user: result.user, - workspace: result.workspace + workspace: result.workspace, + session: { + expiresAt: result.expiresAt + } })); return true; } @@ -76,6 +95,18 @@ export async function handleAuthApiRequest( } const identity = requireIdentity(context); + if (root === "csrf" && segments[1] === "refresh" && request.method === "POST") { + const rotated = context.authService.rotateCsrf(identity); + appendCsrfCookie(response, rotated.csrfToken, { + path: context.cookiePath, + secure: context.cookieSecure, + maxAgeSeconds: rotated.maxAgeSeconds + }); + sendJson(response, 200, createSuccessResult({ csrfToken: rotated.csrfToken }), { + "Cache-Control": "no-store" + }); + return true; + } if (isUnsafeMethod(request.method)) { context.authService.validateCsrf(identity, headerString(request.headers["x-csrf-token"])); } @@ -86,13 +117,19 @@ export async function handleAuthApiRequest( } if (root === "logout" && request.method === "POST") { const result = context.authService.logout(identity); - appendClearAuthCookies(response); + appendClearAuthCookies(response, { + path: context.cookiePath, + secure: context.cookieSecure + }); sendJson(response, 200, createSuccessResult(result)); return true; } if (root === "logout-all" && request.method === "POST") { const result = context.authService.logoutAll(identity); - appendClearAuthCookies(response); + appendClearAuthCookies(response, { + path: context.cookiePath, + secure: context.cookieSecure + }); sendJson(response, 200, createSuccessResult(result)); return true; } @@ -178,6 +215,16 @@ function optionalString(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value.trim() : undefined; } +function optionalClient(value: unknown): "web" | "tui" | undefined { + if (value === undefined || value === null || value === "") { + return undefined; + } + if (value === "web" || value === "tui") { + return value; + } + throw new AuthError(400, "BAD_REQUEST", "client must be web or tui."); +} + function requestMeta(request: IncomingMessage): { ipAddress?: string | undefined; userAgent?: string | undefined } { return { ...(headerString(request.headers["x-forwarded-for"]) ?? request.socket.remoteAddress @@ -191,11 +238,21 @@ function headerString(value: string | string[] | undefined): string | undefined return Array.isArray(value) ? value[0] : value; } -function sendJson(response: ServerResponse, statusCode: number, body: unknown): void { +function sendJson( + response: ServerResponse, + statusCode: number, + body: unknown, + extraHeaders: Record = {} +): void { + const existingCookies = response.getHeader("Set-Cookie"); response.writeHead(statusCode, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Credentials": "true", - "Content-Type": "application/json; charset=utf-8" + "Content-Type": "application/json; charset=utf-8", + ...extraHeaders, + ...(existingCookies + ? { "Set-Cookie": existingCookies as string | string[] } + : {}) }); response.end(JSON.stringify(body)); } diff --git a/apps/api/src/auth/service.ts b/apps/api/src/auth/service.ts index 8115769e..082eceaf 100644 --- a/apps/api/src/auth/service.ts +++ b/apps/api/src/auth/service.ts @@ -10,10 +10,13 @@ import type { PasswordAuthConfig } from "./config.js"; import { AuthMailer } from "./mailer.js"; import { createSecretToken, hashPassword, hashToken, verifyPassword } from "./crypto.js"; -const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30; +const WEB_SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; +const TUI_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; const EMAIL_VERIFICATION_TTL_MS = 1000 * 60 * 60 * 24; const PASSWORD_RESET_TTL_MS = 1000 * 60 * 30; +export type AuthClientKind = "web" | "tui"; + export type AuthUserDto = { id: string; email?: string; @@ -40,6 +43,7 @@ export class AuthError extends Error { export class AuthService { private readonly mailer: AuthMailer; + private readonly dummyPasswordHash = hashPassword(createSecretToken()).then((result) => result.hash); constructor( private readonly metadataStore: MetadataStore, @@ -48,6 +52,13 @@ export class AuthService { this.mailer = new AuthMailer(config); } + getPublicStatus(): { publicBaseUrl: string; registrationEnabled: boolean } { + return { + publicBaseUrl: this.config.publicBaseUrl, + registrationEnabled: this.config.registrationMode === "open" + }; + } + async register(input: { displayName?: string | undefined; email: string; @@ -55,6 +66,9 @@ export class AuthService { password: string; userAgent?: string | undefined; }): Promise<{ user: AuthUserDto; workspace: AuthWorkspaceDto; verificationToken?: string }> { + if (this.config.registrationMode !== "open") { + throw new AuthError(403, "REGISTRATION_CLOSED", "Registration is closed for this deployment."); + } const email = normalizeEmail(input.email); assertPassword(input.password); this.checkRateLimit(`register:ip:${input.ipAddress ?? "unknown"}`, 5, 60 * 60); @@ -109,12 +123,14 @@ export class AuthService { } async login(input: { + client?: AuthClientKind | undefined; email: string; ipAddress?: string | undefined; password: string; userAgent?: string | undefined; }): Promise<{ csrfToken: string; + expiresAt: string; maxAgeSeconds: number; sessionToken: string; user: AuthUserDto; @@ -124,29 +140,34 @@ export class AuthService { this.checkRateLimit(`login:email:${email}`, 5, 60); this.checkRateLimit(`login:ip:${input.ipAddress ?? "unknown"}`, 20, 60); const user = this.metadataStore.users.findByEmail({ email }); - if (!user || user.disabled_at) { + const credential = user + ? this.metadataStore.userPasswordCredentials.find({ user_id: user.id }) + : undefined; + if (!user || user.disabled_at || !credential) { + await verifyPassword(await this.dummyPasswordHash, input.password); this.audit("auth.login_failed", { email, ipAddress: input.ipAddress, userAgent: input.userAgent }); throw new AuthError(401, "UNAUTHORIZED", "Invalid email or password."); } - if (!user.email_verified_at) { - this.audit("auth.login_unverified", { + if (!(await verifyPassword(credential.password_hash, input.password))) { + this.audit("auth.login_failed", { email, ipAddress: input.ipAddress, userAgent: input.userAgent, userId: user.id }); - throw new AuthError(403, "EMAIL_NOT_VERIFIED", "Email verification is required before login."); + throw new AuthError(401, "UNAUTHORIZED", "Invalid email or password."); } - const credential = this.metadataStore.userPasswordCredentials.find({ user_id: user.id }); - if (!credential || !(await verifyPassword(credential.password_hash, input.password))) { - this.audit("auth.login_failed", { + if (!user.email_verified_at) { + this.audit("auth.login_unverified", { email, ipAddress: input.ipAddress, userAgent: input.userAgent, userId: user.id }); - throw new AuthError(401, "UNAUTHORIZED", "Invalid email or password."); + throw new AuthError(403, "EMAIL_NOT_VERIFIED", "Email verification is required before login."); } + const maxAgeSeconds = input.client === "tui" ? TUI_SESSION_TTL_SECONDS : WEB_SESSION_TTL_SECONDS; + const expiresAt = new Date(Date.now() + maxAgeSeconds * 1000).toISOString(); const sessionToken = createSecretToken(); const csrfToken = createSecretToken(); const session = this.metadataStore.authSessions.create({ @@ -154,7 +175,7 @@ export class AuthService { user_id: user.id, token_hash: hashToken(sessionToken, this.config.sessionSecret), csrf_token_hash: hashToken(csrfToken, this.config.sessionSecret), - expires_at: new Date(Date.now() + SESSION_TTL_SECONDS * 1000).toISOString(), + expires_at: expiresAt, ...(input.ipAddress ? { ip_address: input.ipAddress } : {}), ...(input.userAgent ? { user_agent: input.userAgent } : {}) }); @@ -162,13 +183,14 @@ export class AuthService { this.audit("auth.login_succeeded", { email, ipAddress: input.ipAddress, - metadata: { sessionId: session.id }, + metadata: { sessionId: session.id, client: input.client ?? "web" }, userAgent: input.userAgent, userId: user.id }); return { csrfToken, - maxAgeSeconds: SESSION_TTL_SECONDS, + expiresAt: session.expires_at, + maxAgeSeconds, sessionToken, user: userDto(user), workspace: workspaceDto(workspace) @@ -291,12 +313,33 @@ export class AuthService { validateCsrf(identity: AuthIdentity, csrfToken: string | undefined): void { if (!identity.session || !csrfToken) { - throw new AuthError(403, "FORBIDDEN", "CSRF token is required."); + throw new AuthError(403, "CSRF_INVALID", "CSRF token is required."); } const tokenHash = hashToken(csrfToken, this.config.sessionSecret); if (tokenHash !== identity.session.csrf_token_hash) { - throw new AuthError(403, "FORBIDDEN", "CSRF token is invalid."); + throw new AuthError(403, "CSRF_INVALID", "CSRF token is invalid."); + } + } + + rotateCsrf(identity: AuthIdentity): { csrfToken: string; maxAgeSeconds: number } { + if (!identity.session) { + throw new AuthError(401, "UNAUTHORIZED", "Authentication required."); + } + const csrfToken = createSecretToken(); + try { + this.metadataStore.authSessions.rotateCsrf({ + id: identity.session.id, + csrf_token_hash: hashToken(csrfToken, this.config.sessionSecret) + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("AUTH_SESSION_CSRF_ROTATE_FAILED:")) { + throw new AuthError(401, "UNAUTHORIZED", "Authentication required."); + } + throw error; } + const remainingMs = Date.parse(identity.session.expires_at) - Date.now(); + const maxAgeSeconds = Math.max(0, Math.floor(remainingMs / 1000)); + return { csrfToken, maxAgeSeconds }; } logout(identity: AuthIdentity): { ok: boolean } { diff --git a/apps/api/src/interaction-runtime-adapter.test.ts b/apps/api/src/interaction-runtime-adapter.test.ts new file mode 100644 index 00000000..e372aa63 --- /dev/null +++ b/apps/api/src/interaction-runtime-adapter.test.ts @@ -0,0 +1,110 @@ +import { EventType, type BaseEvent } from "@ag-ui/client"; +import { describe, expect, it } from "vitest"; + +import { + buildHitlSuspendBridgeEvents, + type HitlToolCallBoundaryState, + type InteractionInterrupt +} from "./interaction-runtime-adapter.js"; + +const interrupt: InteractionInterrupt = { + args: { question: "继续吗?" }, + resumeSchema: { type: "string" }, + runId: "run-1", + suspendPayload: { question: "继续吗?" }, + toolCallId: "call_hitl_1", + toolName: "ask_user" +}; + +const interactionEvent = { + type: EventType.CUSTOM, + name: "interaction.requested", + value: { tool_call_id: interrupt.toolCallId }, + timestamp: 1 +} as BaseEvent; + +const onInterruptEvent = { + type: EventType.CUSTOM, + name: "on_interrupt", + value: JSON.stringify(interrupt), + timestamp: 2 +} as BaseEvent; + +function emptyState(): HitlToolCallBoundaryState { + return { + startedToolCallIds: new Set(), + endedToolCallIds: new Set() + }; +} + +describe("buildHitlSuspendBridgeEvents", () => { + it("synthesizes START then END when upstream never started the tool call", () => { + const state = emptyState(); + const events = buildHitlSuspendBridgeEvents({ + interrupt, + interactionEvent, + passthroughInterruptEvent: onInterruptEvent, + state + }); + + expect(events.map((event) => event.type)).toEqual([ + EventType.TOOL_CALL_START, + EventType.CUSTOM, + EventType.CUSTOM, + EventType.TOOL_CALL_END + ]); + expect(events[0]).toMatchObject({ + type: EventType.TOOL_CALL_START, + toolCallId: interrupt.toolCallId, + toolCallName: "ask_user" + }); + expect(events[1]).toBe(interactionEvent); + expect(events[2]).toBe(onInterruptEvent); + expect(events[3]).toMatchObject({ + type: EventType.TOOL_CALL_END, + toolCallId: interrupt.toolCallId + }); + expect(state.startedToolCallIds.has(interrupt.toolCallId)).toBe(true); + expect(state.endedToolCallIds.has(interrupt.toolCallId)).toBe(true); + }); + + it("still emits END before transport RUN_FINISHED when START already arrived", () => { + const state = emptyState(); + state.startedToolCallIds.add(interrupt.toolCallId); + + const events = buildHitlSuspendBridgeEvents({ + interrupt, + interactionEvent, + passthroughInterruptEvent: onInterruptEvent, + state + }); + + expect(events.map((event) => event.type)).toEqual([ + EventType.CUSTOM, + EventType.CUSTOM, + EventType.TOOL_CALL_END + ]); + expect(events.at(-1)).toMatchObject({ + type: EventType.TOOL_CALL_END, + toolCallId: interrupt.toolCallId + }); + // Caller appends transport-only RUN_FINISHED after this bridge sequence. + expect(events.some((event) => event.type === EventType.RUN_FINISHED)).toBe(false); + expect(state.endedToolCallIds.has(interrupt.toolCallId)).toBe(true); + }); + + it("does not duplicate END when the tool call already ended", () => { + const state = emptyState(); + state.startedToolCallIds.add(interrupt.toolCallId); + state.endedToolCallIds.add(interrupt.toolCallId); + + const events = buildHitlSuspendBridgeEvents({ + interrupt, + interactionEvent, + state + }); + + expect(events.map((event) => event.type)).toEqual([EventType.CUSTOM]); + expect(events[0]).toBe(interactionEvent); + }); +}); diff --git a/apps/api/src/interaction-runtime-adapter.ts b/apps/api/src/interaction-runtime-adapter.ts index cca5f335..d0fa759c 100644 --- a/apps/api/src/interaction-runtime-adapter.ts +++ b/apps/api/src/interaction-runtime-adapter.ts @@ -121,6 +121,57 @@ export const buildHitlToolCallStartEvent = (interrupt: InteractionInterrupt): Ba timestamp: Date.now() }) as BaseEvent; +/** + * Pair with {@link buildHitlToolCallStartEvent} before the transport-only RUN_FINISHED. + * Mastra's on_interrupt path never emits TOOL_CALL_END; without it, AbstractAgent + * verifyEvents rejects RUN_FINISHED while the tool call is still active. + */ +export const buildHitlToolCallEndEvent = (interrupt: InteractionInterrupt): BaseEvent => + ({ + type: EventType.TOOL_CALL_END, + toolCallId: interrupt.toolCallId, + toolCallName: interrupt.toolName, + timestamp: Date.now() + }) as BaseEvent; + +export type HitlToolCallBoundaryState = { + endedToolCallIds: Set; + startedToolCallIds: Set; +}; + +/** + * Server event-bridge for HITL suspend (persisted stream events only). + * Ensures START (if missing) and END (if missing) around the interaction / + * on_interrupt events. Caller must still send a transport-only RUN_FINISHED + * via the subscriber (not through the persist pipeline). + */ +export function buildHitlSuspendBridgeEvents(input: { + interrupt: InteractionInterrupt; + interactionEvent: BaseEvent; + passthroughInterruptEvent?: BaseEvent; + state: HitlToolCallBoundaryState; +}): BaseEvent[] { + const toolCallId = input.interrupt.toolCallId; + const events: BaseEvent[] = []; + + if (!input.state.startedToolCallIds.has(toolCallId)) { + events.push(buildHitlToolCallStartEvent(input.interrupt)); + input.state.startedToolCallIds.add(toolCallId); + } + + events.push(input.interactionEvent); + if (input.passthroughInterruptEvent) { + events.push(input.passthroughInterruptEvent); + } + + if (!input.state.endedToolCallIds.has(toolCallId)) { + events.push(buildHitlToolCallEndEvent(input.interrupt)); + input.state.endedToolCallIds.add(toolCallId); + } + + return events; +} + /** Extract a Mastra resume command from an AG-UI run request. */ export const extractInteractionResume = (input: RunAgentInput): InteractionResume | undefined => { if (!isRecord(input.forwardedProps) || !isRecord(input.forwardedProps.command)) { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 395c74b1..6aa07187 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -65,7 +65,7 @@ import { resolveRunIdentity } from "./run-identity-orchestrator.js"; import { createRunMemoryAssembly } from "./run-memory-assembly.js"; import { extractLastUserText } from "./run-input.js"; import { - buildHitlToolCallStartEvent, + buildHitlSuspendBridgeEvents, extractInteractionResume, InteractionRuntimeAdapter } from "./interaction-runtime-adapter.js"; @@ -277,7 +277,12 @@ export const createServer = async (options: CreateServerOptions = {}): Promise(); + const endedToolCallIds = new Set(); const clearRunTimeout = (): void => { if (runTimeout) { clearTimeout(runTimeout); @@ -797,17 +803,23 @@ class DataFoundryAgUiAgent extends AbstractAgent { clearRunTimeout(); unregisterCancel(); suspended = true; - // R-018: persist TOOL_CALL_START with the interactions row when the stream - // never emitted one (common for Mastra on_interrupt before tool-start). - if (!startedToolCallIds.has(interactionRequested.interrupt.toolCallId)) { - emit(buildHitlToolCallStartEvent(interactionRequested.interrupt)); - startedToolCallIds.add(interactionRequested.interrupt.toolCallId); - } - emit(interactionRequested.event); - finalizer.suspend(); - // CopilotKit useInterrupt listens for the native Mastra interrupt event. - if (event.type === EventType.CUSTOM && event.name === "on_interrupt") { - emit(event); + // R-018 / AG-UI verifyEvents: close the tool-call span before transport RUN_FINISHED + // whether upstream already emitted START or Mastra skipped start/end entirely. + const bridgeEvents = buildHitlSuspendBridgeEvents({ + interrupt: interactionRequested.interrupt, + interactionEvent: interactionRequested.event, + ...(event.type === EventType.CUSTOM && event.name === "on_interrupt" + ? { passthroughInterruptEvent: event } + : {}), + state: { startedToolCallIds, endedToolCallIds } + }); + for (const bridgeEvent of bridgeEvents) { + if (bridgeEvent === interactionRequested.event) { + emit(bridgeEvent); + finalizer.suspend(); + continue; + } + emit(bridgeEvent); } // Stream must finalize so CopilotKit can surface the interrupt UI via onRunFinalized. // This synthetic terminal event is transport-only; suspended runs must not replay as finished. @@ -865,6 +877,13 @@ class DataFoundryAgUiAgent extends AbstractAgent { ) { startedToolCallIds.add(event.toolCallId); } + if ( + event.type === EventType.TOOL_CALL_END + && typeof event.toolCallId === "string" + && event.toolCallId.length > 0 + ) { + endedToolCallIds.add(event.toolCallId); + } emit(event); if (event.type === EventType.RUN_STARTED) { diff --git a/apps/web/.env.example b/apps/web/.env.example index 4299170a..2dcfb935 100755 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,8 +4,10 @@ # NEXT_PUBLIC_* values are baked in at `npm run build:web`. # # --- Formal test / real production (default) --- -# Same frontend settings for both; differ in root AUTH_EMAIL_DELIVERY and -# AUTH_PUBLIC_BASE_URL (see root .env.example). +# Same frontend settings for both; differ in root AUTH_EMAIL_DELIVERY, +# AUTH_PUBLIC_BASE_URL, and AUTH_REGISTRATION_MODE (see root .env.example). +# Registration UI may still follow NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE until +# M0A.5c reads GET /api/v1/auth/status; API policy is authoritative. NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password # Leave empty so the browser uses `/api/v1/*` and `/api/copilotkit` on the # Next origin (required for session cookies + CSRF). diff --git a/package.json b/package.json index feaad081..66714186 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "smoke:long-term-memory": "npm run build && node scripts/smoke-long-term-memory.mjs", "smoke:memory-recall-shadow": "npm run build && node scripts/smoke-memory-recall-shadow.mjs", "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:files": "npm run build && node scripts/smoke-files.mjs", "smoke:skills": "npm run build && node scripts/smoke-skills.mjs", diff --git a/packages/agent-runtime/src/tools/governed-tool-factory.test.ts b/packages/agent-runtime/src/tools/governed-tool-factory.test.ts index d724e625..72845e54 100644 --- a/packages/agent-runtime/src/tools/governed-tool-factory.test.ts +++ b/packages/agent-runtime/src/tools/governed-tool-factory.test.ts @@ -140,6 +140,65 @@ describe("GovernedToolFactory protocol boundary", () => { }); }); + it("bypasses ActionRouter for externally resolved HITL tools so suspend can propagate", async () => { + let actionRouterCalled = false; + let suspendedPayload: unknown; + const runScope = { modelName: "test", resourceId: "user-1", runId: "run-1", sessionId: "session-1" }; + const boundary = createToolObservationBoundary({ identity: runScope }); + const dispatcher = new ToolObservationDispatcher(boundary.packager, runScope); + const factory = new GovernedToolFactory(dispatcher, undefined, undefined, { + actionRouter: { + execute: async () => { + actionRouterCalled = true; + return { + rawResult: { shouldNotRun: true }, + observation: { shouldNotRun: true }, + contextPackageRef: { packageId: "context-1", revision: 1 }, + contextPackage: { + version: 2 as const, + packageId: "context-1", + revision: 1, + items: [], + groups: [], + sourceSnapshots: [], + artifactRefs: [], + auditRefs: [], + truncation: [] + } + }; + } + }, + externallyResolvedToolNames: new Set(["ask_user"]), + runId: "run-1", + segmentId: "segment-1" + }); + const tool = factory.governTool("ask_user", { + execute: async (_input: unknown, options?: unknown) => { + const agent = (options as { agent?: { + suspend?: (payload: unknown) => Promise; + } } | undefined)?.agent; + await agent?.suspend?.({ question: "Which datasource?" }); + return undefined; + } + }); + + const result = await tool.execute?.( + { question: "Which datasource?" }, + { + agent: { + toolCallId: "call-ask", + suspend: async (payload: unknown) => { + suspendedPayload = payload; + } + } + } + ); + + expect(actionRouterCalled).toBe(false); + expect(result).toBeUndefined(); + expect(suspendedPayload).toEqual({ question: "Which datasource?" }); + }); + it("warns the model not to repeat an external action whose state commit failed", async () => { const emitted: unknown[] = []; const runScope = { modelName: "test", resourceId: "user-1", runId: "run-1", sessionId: "session-1" }; diff --git a/packages/agent-runtime/src/tools/governed-tool-factory.ts b/packages/agent-runtime/src/tools/governed-tool-factory.ts index 7f5ae1fe..5486ca51 100644 --- a/packages/agent-runtime/src/tools/governed-tool-factory.ts +++ b/packages/agent-runtime/src/tools/governed-tool-factory.ts @@ -91,7 +91,12 @@ export class GovernedToolFactory { const toolCallId = toolCallIdFromOptions(options); const toolInput = args[0]; try { - if (this.actionRouter) { + // HITL tools (ask_user / submit_plan) must call Mastra suspend directly. + // Routing them through ActionRouter records a synthetic success for `undefined` + // and breaks the on_interrupt → interaction.requested contract. + const useActionRouter = Boolean(this.actionRouter) + && !this.externallyResolvedToolNames.has(toolName); + if (useActionRouter && this.actionRouter) { const segmentId = this.getSegmentId?.() ?? this.segmentId; if (!this.runId || !segmentId) { throw new Error("GOVERNED_ACTION_ROUTER_SCOPE_REQUIRED"); diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0301c756..717b7fd4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -255,12 +255,14 @@ export type ArtifactSummary = { export type AppErrorCode = | "BAD_REQUEST" | "CONFLICT" + | "CSRF_INVALID" | "DATASOURCE_TEST_FAILED" | "EMAIL_NOT_VERIFIED" | "FORBIDDEN" | "INTERNAL_ERROR" | "JOB_NOT_FOUND" | "PROVIDER_TEST_FAILED" + | "REGISTRATION_CLOSED" | "REVISION_CONFLICT" | "SECRET_MASTER_KEY_REQUIRED" | "UNAUTHORIZED" diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 5c6f89ad..397cd733 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -1014,6 +1014,18 @@ export class AuthSessionRepository { .run(input.last_seen_at ?? new Date().toISOString(), input.id); } + rotateCsrf(input: { id: string; csrf_token_hash: string }): AuthSessionRecord { + const result = this.db.prepare(` + UPDATE auth_sessions + SET csrf_token_hash = ?, last_seen_at = ? + WHERE id = ? AND revoked_at IS NULL AND expires_at > ? + `).run(input.csrf_token_hash, new Date().toISOString(), input.id, new Date().toISOString()); + if (result.changes !== 1) { + throw new Error(`AUTH_SESSION_CSRF_ROTATE_FAILED:${input.id}`); + } + return this.get({ id: input.id }); + } + revoke(input: { id: string; revoked_at?: string }): void { this.db.prepare("UPDATE auth_sessions SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?") .run(input.revoked_at ?? new Date().toISOString(), input.id); diff --git a/scripts/auth-foundation.test.mjs b/scripts/auth-foundation.test.mjs new file mode 100644 index 00000000..ac9e8ac8 --- /dev/null +++ b/scripts/auth-foundation.test.mjs @@ -0,0 +1,659 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomUUID } from "node:crypto"; + +import { createServer as createApiServer } from "../apps/api/dist/server.js"; +import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { + loadPasswordAuthConfig, + validateAuthPublicUrl +} from "../apps/api/dist/auth/config.js"; +import { + appendAuthCookies, + appendClearAuthCookies +} from "../apps/api/dist/auth/cookies.js"; + +const SCRIPTS_ROOT = dirname(fileURLToPath(import.meta.url)); + +const FORMAL_HTTP_AUTH_TARGETS = [ + "run-dacomp6-complex-case.mjs", + "seed-dtc-growth-demo.mjs", + "seed-local-fixtures.mjs", + "smoke-agent-protocol-deepseek.mjs", + "smoke-ask-user-interrupt.mjs", + "smoke-auth.mjs", + "smoke-config-api.mjs", + "smoke-copilotkit-run.mjs", + "smoke-copilotkit.mjs", + "smoke-interaction-run-id.mjs", + "smoke-password-frontend-isolation.mjs", + "smoke-server-datasources-e2e.mjs", + "test-builtin-dtc-growth-datasource.mjs", + "verify-token-usage-display.mjs" +]; + +const PUBLIC_HTTP_TARGETS = [ + "deploy/health.mjs", + "deploy/smoke-native-deploy.mjs" +]; + +const DIRECT_METADATA_FIXTURE_TARGETS = []; + +const AUTH_FOUNDATION_HARNESS_TARGETS = [ + "auth-foundation.test.mjs", + "lib/authenticated-test-client.test.mjs" +]; + +const FORBIDDEN_DEV_AUTH_PATTERNS = [ + /X-Dev-Token/i, + /\bdev-token\b/, + /Authorization\s*:\s*["']?Bearer\s+dev\b/i +]; + +function listMjsFiles(dir) { + /** @type {string[]} */ + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + out.push(...listMjsFiles(full)); + } else if (entry.endsWith(".mjs")) { + out.push(full); + } + } + return out; +} + +function isHttpScript(source) { + return /\bfetch\s*\(|\bhttps?\.request\s*\(/.test(source); +} + +const SECRET = "auth-foundation-session-secret-32b!"; + +function baseEnv(overrides = {}) { + return { + DATAFOUNDRY_AUTH_MODE: "password", + AUTH_SESSION_SECRET: SECRET, + AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", + AUTH_EMAIL_DELIVERY: "test", + AUTH_REGISTRATION_MODE: "open", + ...overrides + }; +} + +test("validateAuthPublicUrl allows loopback HTTP without secure cookies", () => { + for (const raw of [ + "http://127.0.0.1:3000", + "http://localhost:3000", + "http://[::1]:3000" + ]) { + const result = validateAuthPublicUrl(raw); + assert.equal(result.loopback, true); + assert.equal(result.cookieSecure, false); + assert.ok(result.publicBaseUrl.startsWith("http://")); + } +}); + +test("validateAuthPublicUrl allows HTTPS with secure cookies and path", () => { + const result = validateAuthPublicUrl("https://example.com/datafoundry"); + assert.equal(result.loopback, false); + assert.equal(result.cookieSecure, true); + assert.equal(result.publicBaseUrl, "https://example.com/datafoundry"); + assert.equal(result.cookiePath, "/datafoundry"); +}); + +test("validateAuthPublicUrl uses root cookie path for origin-only URLs", () => { + const result = validateAuthPublicUrl("https://example.com"); + assert.equal(result.cookiePath, "/"); + assert.equal(result.publicBaseUrl, "https://example.com"); +}); + +test("validateAuthPublicUrl rejects non-loopback HTTP", () => { + assert.throws( + () => validateAuthPublicUrl("http://192.168.1.10:3000"), + /AUTH_PUBLIC_BASE_URL|loopback|HTTPS/i + ); +}); + +test("validateAuthPublicUrl rejects illegal URL shapes", () => { + assert.throws(() => validateAuthPublicUrl("ftp://localhost:3000")); + assert.throws(() => validateAuthPublicUrl("http://user:pass@localhost:3000")); + assert.throws(() => validateAuthPublicUrl("http://localhost:3000#frag")); +}); + +test("password config matrix", () => { + const cases = [ + { + name: "loopback 127 with test mail", + env: baseEnv({ AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", AUTH_EMAIL_DELIVERY: "test" }), + ok: true, + cookieSecure: false + }, + { + name: "loopback localhost with test mail", + env: baseEnv({ AUTH_PUBLIC_BASE_URL: "http://localhost:3000", AUTH_EMAIL_DELIVERY: "test" }), + ok: true, + cookieSecure: false + }, + { + name: "loopback ipv6 with test mail", + env: baseEnv({ AUTH_PUBLIC_BASE_URL: "http://[::1]:3000", AUTH_EMAIL_DELIVERY: "test" }), + ok: true, + cookieSecure: false + }, + { + name: "lan http rejected", + env: baseEnv({ AUTH_PUBLIC_BASE_URL: "http://192.168.1.10:3000", AUTH_EMAIL_DELIVERY: "test" }), + ok: false + }, + { + name: "https smtp allowed", + env: baseEnv({ + AUTH_PUBLIC_BASE_URL: "https://example.com/datafoundry", + AUTH_EMAIL_DELIVERY: "smtp", + SMTP_HOST: "smtp.example.com", + SMTP_FROM: "noreply@example.com" + }), + ok: true, + cookieSecure: true, + cookiePath: "/datafoundry" + }, + { + name: "https test mail rejected", + env: baseEnv({ + AUTH_PUBLIC_BASE_URL: "https://example.com", + AUTH_EMAIL_DELIVERY: "test" + }), + ok: false + }, + { + name: "invalid registration mode rejected", + env: baseEnv({ AUTH_REGISTRATION_MODE: "maybe" }), + ok: false + }, + { + name: "missing registration mode rejected in password mode", + env: baseEnv({ AUTH_REGISTRATION_MODE: "" }), + ok: false + }, + { + name: "invalid email delivery rejected", + env: baseEnv({ AUTH_EMAIL_DELIVERY: "console" }), + ok: false + } + ]; + + for (const item of cases) { + if (item.ok) { + const config = loadPasswordAuthConfig(item.env); + assert.equal(config.cookieSecure, item.cookieSecure, item.name); + assert.equal(config.cookiePath, item.cookiePath ?? "/", item.name); + assert.ok(config.registrationMode === "open" || config.registrationMode === "closed", item.name); + } else { + assert.throws(() => loadPasswordAuthConfig(item.env), Error, item.name); + } + } +}); + +async function captureSetCookie(write) { + const server = createServer((req, res) => { + write(res); + res.end("ok"); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + assert(address && typeof address === "object"); + try { + const response = await fetch(`http://127.0.0.1:${address.port}/`); + return response.headers.getSetCookie(); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +test("appendAuthCookies and clear use the same secure flag", async () => { + const secureCookies = await captureSetCookie((res) => { + appendAuthCookies(res, { + sessionToken: "session", + csrfToken: "csrf", + maxAgeSeconds: 60, + path: "/", + secure: true + }); + }); + assert.ok(secureCookies.every((cookie) => /;\s*Secure/i.test(cookie))); + + const clearSecure = await captureSetCookie((res) => { + appendClearAuthCookies(res, { path: "/", secure: true }); + }); + assert.ok(clearSecure.every((cookie) => /;\s*Secure/i.test(cookie))); + + const insecureCookies = await captureSetCookie((res) => { + appendAuthCookies(res, { + sessionToken: "session", + csrfToken: "csrf", + maxAgeSeconds: 60, + path: "/", + secure: false + }); + }); + assert.ok(insecureCookies.every((cookie) => !/;\s*Secure/i.test(cookie))); + + const clearInsecure = await captureSetCookie((res) => { + appendClearAuthCookies(res, { path: "/", secure: false }); + }); + assert.ok(clearInsecure.every((cookie) => !/;\s*Secure/i.test(cookie))); +}); + +test("cookie helpers derive Path from deployment prefix", async () => { + const cookies = await captureSetCookie((res) => { + appendAuthCookies(res, { + sessionToken: "session", + csrfToken: "csrf", + maxAgeSeconds: 60, + path: "/datafoundry", + secure: true + }); + }); + assert.ok(cookies.every((cookie) => /;\s*Path=\/datafoundry(?:;|$)/i.test(cookie))); + + const cleared = await captureSetCookie((res) => { + appendClearAuthCookies(res, { path: "/datafoundry", secure: true }); + }); + assert.ok(cleared.every((cookie) => /;\s*Path=\/datafoundry(?:;|$)/i.test(cookie))); +}); + +test("cookie helpers no longer read NODE_ENV for Secure", async () => { + const previous = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + try { + const cookies = await captureSetCookie((res) => { + appendAuthCookies(res, { + sessionToken: "session", + csrfToken: "csrf", + maxAgeSeconds: 60, + path: "/", + secure: false + }); + }); + assert.ok(cookies.every((cookie) => !/;\s*Secure/i.test(cookie))); + } finally { + if (previous === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previous; + } + } +}); + +async function withPasswordApi(envOverrides, run) { + const root = mkdtempSync(join(tmpdir(), "datafoundry-auth-foundation-")); + const previous = { ...process.env }; + Object.assign(process.env, { + DATAFOUNDRY_AUTH_MODE: "password", + AUTH_SESSION_SECRET: SECRET, + AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", + AUTH_EMAIL_DELIVERY: "test", + AUTH_REGISTRATION_MODE: "open", + EMBEDDING_API_KEY: "", + MASTRA_STORAGE_PATH: join(root, "mastra.sqlite"), + STORAGE_ROOT_DIR: root, + ...envOverrides + }); + + const metadataStore = createMetadataStore({ + database_path: join(root, "metadata.sqlite"), + secret_master_key: "auth-foundation-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}`; + + try { + await run({ baseUrl, metadataStore }); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + setImmediate(() => server.closeAllConnections?.()); + }); + for (const key of Object.keys(process.env)) { + if (!(key in previous)) { + delete process.env[key]; + } + } + Object.assign(process.env, previous); + } +} + +test("GET /api/v1/auth/status is public and returns safe fields only", async () => { + await withPasswordApi({ AUTH_REGISTRATION_MODE: "open" }, async ({ baseUrl }) => { + const response = await fetch(`${baseUrl}/api/v1/auth/status`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(Object.keys(body.data).sort(), ["publicBaseUrl", "registrationEnabled"]); + assert.equal(body.data.publicBaseUrl, "http://127.0.0.1:3000"); + assert.equal(body.data.registrationEnabled, true); + const serialized = JSON.stringify(body); + assert.doesNotMatch(serialized, /AUTH_SESSION_SECRET|smtp|secret|8787|master/i); + }); +}); + +test("closed registration rejects register with REGISTRATION_CLOSED", async () => { + await withPasswordApi({ AUTH_REGISTRATION_MODE: "closed" }, async ({ baseUrl, metadataStore }) => { + const status = await fetch(`${baseUrl}/api/v1/auth/status`); + const statusBody = await status.json(); + assert.equal(statusBody.data.registrationEnabled, false); + + const email = `${randomUUID()}@example.test`; + const response = await fetch(`${baseUrl}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password: "correct-horse", + displayName: "Closed User" + }) + }); + assert.equal(response.status, 403); + const body = await response.json(); + assert.equal(body.error.code, "REGISTRATION_CLOSED"); + assert.equal(metadataStore.users.findByEmail({ email }), undefined); + }); +}); + +test("open registration still allows register", async () => { + await withPasswordApi({ AUTH_REGISTRATION_MODE: "open" }, async ({ baseUrl }) => { + const email = `${randomUUID()}@example.test`; + const response = await fetch(`${baseUrl}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password: "correct-horse", + displayName: "Open User" + }) + }); + assert.equal(response.status, 201, await response.clone().text()); + }); +}); + +async function registerVerify(baseUrl, { email, password, displayName }) { + const registered = await fetch(`${baseUrl}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, displayName }) + }); + assert.equal(registered.status, 201, await registered.clone().text()); + const body = await registered.json(); + const verified = await fetch(`${baseUrl}/api/v1/auth/verify-email`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: body.data.verificationToken }) + }); + assert.equal(verified.status, 200, await verified.clone().text()); + return body; +} + +test("CSRF_INVALID and recoverable csrf refresh", async () => { + await withPasswordApi({}, async ({ baseUrl }) => { + const email = `${randomUUID()}@example.test`; + const password = "correct-horse-battery"; + await registerVerify(baseUrl, { email, password, displayName: "Csrf User" }); + + const login = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }) + }); + assert.equal(login.status, 200); + const setCookies = login.headers.getSetCookie(); + const cookieHeader = setCookies.map((item) => item.split(";", 1)[0]).join("; "); + const oldCsrf = setCookies + .find((item) => item.startsWith("df_csrf=")) + ?.split(";", 1)[0] + ?.slice("df_csrf=".length); + assert.ok(oldCsrf); + + const missing = await fetch(`${baseUrl}/api/v1/datasources`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader + }, + body: JSON.stringify({ + id: `ds-${randomUUID()}`, + name: "No CSRF", + type: "sqlite", + settings: { filePath: ":memory:" } + }) + }); + assert.equal(missing.status, 403); + assert.equal((await missing.json()).error.code, "CSRF_INVALID"); + + const anonymousRefresh = await fetch(`${baseUrl}/api/v1/auth/csrf/refresh`, { + method: "POST" + }); + assert.equal(anonymousRefresh.status, 401); + + const refresh = await fetch(`${baseUrl}/api/v1/auth/csrf/refresh`, { + method: "POST", + headers: { Cookie: cookieHeader } + }); + assert.equal(refresh.status, 200, await refresh.clone().text()); + assert.equal(refresh.headers.get("cache-control"), "no-store"); + const refreshBody = await refresh.json(); + const newCsrf = refreshBody.data.csrfToken; + assert.equal(typeof newCsrf, "string"); + assert.notEqual(newCsrf, decodeURIComponent(oldCsrf)); + const refreshedCookies = refresh.headers.getSetCookie(); + assert.ok(refreshedCookies.some((item) => item.startsWith("df_csrf="))); + const refreshedCookieHeader = [ + ...cookieHeader + .split("; ") + .filter((part) => !part.startsWith("df_csrf=")), + `df_csrf=${encodeURIComponent(newCsrf)}` + ].join("; "); + + const oldStillInvalid = await fetch(`${baseUrl}/api/v1/datasources`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + "X-CSRF-Token": decodeURIComponent(oldCsrf) + }, + body: JSON.stringify({ + id: `ds-${randomUUID()}`, + name: "Old CSRF", + type: "sqlite", + settings: { filePath: ":memory:" } + }) + }); + assert.equal(oldStillInvalid.status, 403); + assert.equal((await oldStillInvalid.json()).error.code, "CSRF_INVALID"); + + const withNew = await fetch(`${baseUrl}/api/v1/datasources`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Cookie: refreshedCookieHeader, + "X-CSRF-Token": newCsrf + }, + body: JSON.stringify({ + id: `ds-${randomUUID()}`, + name: "New CSRF", + type: "sqlite", + settings: { filePath: ":memory:" } + }) + }); + assert.equal(withNew.status, 201, await withNew.clone().text()); + }); +}); + +test("login anti-enumeration and client session lifetimes", async () => { + await withPasswordApi({}, async ({ baseUrl, metadataStore }) => { + const password = "correct-horse-battery"; + const verifiedEmail = `${randomUUID()}@example.test`; + const unverifiedEmail = `${randomUUID()}@example.test`; + + await registerVerify(baseUrl, { + email: verifiedEmail, + password, + displayName: "Verified" + }); + + const unverified = await fetch(`${baseUrl}/api/v1/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: unverifiedEmail, + password, + displayName: "Unverified" + }) + }); + assert.equal(unverified.status, 201); + + const missing = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: `${randomUUID()}@example.test`, password: "whatever-password" }) + }); + assert.equal(missing.status, 401); + const missingBody = await missing.json(); + assert.equal(missingBody.error.message, "Invalid email or password."); + + const wrongPassword = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: verifiedEmail, password: "wrong-password" }) + }); + assert.equal(wrongPassword.status, 401); + const wrongBody = await wrongPassword.json(); + assert.equal(wrongBody.error.message, "Invalid email or password."); + + const unverifiedWrong = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: unverifiedEmail, password: "wrong-password" }) + }); + assert.equal(unverifiedWrong.status, 401); + assert.equal((await unverifiedWrong.json()).error.message, "Invalid email or password."); + + const unverifiedCorrect = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: unverifiedEmail, password }) + }); + assert.equal(unverifiedCorrect.status, 403); + assert.equal((await unverifiedCorrect.json()).error.code, "EMAIL_NOT_VERIFIED"); + + const illegalClient = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: verifiedEmail, password, client: "mobile" }) + }); + assert.equal(illegalClient.status, 400); + + const webLogin = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: verifiedEmail, password, client: "web" }) + }); + assert.equal(webLogin.status, 200, await webLogin.clone().text()); + const webBody = await webLogin.json(); + assert.equal(typeof webBody.data.session.expiresAt, "string"); + const webExpires = Date.parse(webBody.data.session.expiresAt); + const webDelta = webExpires - Date.now(); + assert.ok(Math.abs(webDelta - 30 * 24 * 60 * 60 * 1000) < 120_000); + + const webCookie = webLogin.headers.getSetCookie().find((item) => item.startsWith("df_session=")); + assert.match(webCookie ?? "", /Max-Age=2592000/i); + + const tuiLogin = await fetch(`${baseUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: verifiedEmail, password, client: "tui" }) + }); + assert.equal(tuiLogin.status, 200, await tuiLogin.clone().text()); + const tuiBody = await tuiLogin.json(); + const tuiExpires = Date.parse(tuiBody.data.session.expiresAt); + const tuiDelta = tuiExpires - Date.now(); + assert.ok(Math.abs(tuiDelta - 7 * 24 * 60 * 60 * 1000) < 120_000); + const tuiCookie = tuiLogin.headers.getSetCookie().find((item) => item.startsWith("df_session=")); + assert.match(tuiCookie ?? "", /Max-Age=604800/i); + + const sessions = metadataStore.authSessions.listByUser + ? metadataStore.authSessions.listByUser({ user_id: webBody.data.user.id }) + : undefined; + if (Array.isArray(sessions) && sessions.length > 0) { + const matching = sessions.find((session) => session.expires_at === tuiBody.data.session.expiresAt) + ?? sessions.find((session) => session.expires_at === webBody.data.session.expiresAt); + assert.ok(matching, "expected session expires_at to match login response"); + } + }); +}); + +test("scripts/**/*.mjs HTTP auth classification gate", () => { + const classified = new Set([ + ...FORMAL_HTTP_AUTH_TARGETS, + ...PUBLIC_HTTP_TARGETS, + ...DIRECT_METADATA_FIXTURE_TARGETS, + ...AUTH_FOUNDATION_HARNESS_TARGETS + ]); + const httpScripts = listMjsFiles(SCRIPTS_ROOT) + .map((fullPath) => ({ + fullPath, + relativePath: relative(SCRIPTS_ROOT, fullPath).replaceAll("\\", "/") + })) + .filter(({ fullPath }) => isHttpScript(readFileSync(fullPath, "utf8"))); + + const unclassified = httpScripts + .map((item) => item.relativePath) + .filter((path) => !classified.has(path)) + .sort(); + assert.deepEqual( + unclassified, + [], + `Unclassified HTTP scripts must be listed in FORMAL_HTTP_AUTH_TARGETS, PUBLIC_HTTP_TARGETS, DIRECT_METADATA_FIXTURE_TARGETS, or AUTH_FOUNDATION_HARNESS_TARGETS:\n${unclassified.join("\n")}` + ); + + const formalViolations = []; + for (const relativePath of FORMAL_HTTP_AUTH_TARGETS) { + const fullPath = join(SCRIPTS_ROOT, relativePath); + const source = readFileSync(fullPath, "utf8"); + for (const pattern of FORBIDDEN_DEV_AUTH_PATTERNS) { + if (pattern.test(source)) { + formalViolations.push(`${relativePath} matches ${pattern}`); + } + } + if (!source.includes("authenticated-test-client")) { + formalViolations.push( + `${relativePath} must import scripts/lib/authenticated-test-client (shared password-auth client)` + ); + } + const syntaxCheck = spawnSync(process.execPath, ["--check", fullPath], { + encoding: "utf8" + }); + if (syntaxCheck.status !== 0) { + const detail = [syntaxCheck.stderr, syntaxCheck.stdout].filter(Boolean).join("\n").trim(); + formalViolations.push( + `${relativePath} failed node --check${detail ? `:\n${detail}` : ""}` + ); + } + } + assert.deepEqual( + formalViolations, + [], + `FORMAL_HTTP_AUTH_TARGETS must not use development auth bypasses, must use the shared client, and must pass node --check:\n${formalViolations.join("\n")}` + ); +}); diff --git a/scripts/lib/authenticated-test-client.mjs b/scripts/lib/authenticated-test-client.mjs new file mode 100644 index 00000000..3546be94 --- /dev/null +++ b/scripts/lib/authenticated-test-client.mjs @@ -0,0 +1,222 @@ +import { randomUUID } from "node:crypto"; + +const UNSAFE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); + +export function resolveApiUrl(baseUrl, relativePath) { + const base = new URL(baseUrl); + const prefix = base.pathname.replace(/\/?$/, "/"); + // Preserve query/hash from relativePath; strip only base URL search/hash. + const resolved = new URL(String(relativePath).replace(/^\/+/, ""), `https://resolve.invalid${prefix}`); + base.pathname = resolved.pathname; + base.search = resolved.search; + base.hash = ""; + return base; +} + +function createCookieJar() { + /** @type {Record} */ + const store = Object.create(null); + + return { + replace(cookies) { + for (const key of Object.keys(store)) { + delete store[key]; + } + for (const [name, value] of Object.entries(cookies ?? {})) { + store[name] = String(value); + } + }, + absorbSetCookie(headers) { + 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(); + const value = decodeURIComponent(pair.slice(eq + 1)); + store[name] = value; + } + }, + headerValue() { + const parts = Object.entries(store).map( + ([name, value]) => `${name}=${encodeURIComponent(value)}` + ); + return parts.length > 0 ? parts.join("; ") : undefined; + }, + csrfToken() { + return store.df_csrf; + }, + snapshot() { + return { ...store }; + }, + clear() { + for (const key of Object.keys(store)) { + delete store[key]; + } + } + }; +} + +function splitSetCookieHeader(value) { + if (!value) { + return []; + } + if (Array.isArray(value)) { + return value; + } + return [value]; +} + +function normalizeHeaders(initHeaders) { + if (!initHeaders) { + return new Headers(); + } + return initHeaders instanceof Headers ? new Headers(initHeaders) : new Headers(initHeaders); +} + +function createAuthError(status, code, message) { + const error = new Error(message || `HTTP ${status}`); + error.name = "AuthenticatedTestClientError"; + error.status = status; + error.code = code ?? "HTTP_ERROR"; + return error; +} + +async function readJsonSafe(response) { + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("application/json")) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + +export function createAuthenticatedTestClient({ baseUrl, fetchImpl = fetch }) { + const cookies = createCookieJar(); + + async function authenticatedFetch(path, init = {}) { + const { expectOk = false, ...requestInit } = init; + const method = String(requestInit.method ?? "GET").toUpperCase(); + const headers = normalizeHeaders(requestInit.headers); + const cookieHeader = cookies.headerValue(); + if (cookieHeader) { + headers.set("cookie", cookieHeader); + } + if (UNSAFE_METHODS.has(method)) { + const csrf = cookies.csrfToken(); + if (csrf && !headers.has("x-csrf-token")) { + headers.set("x-csrf-token", csrf); + } + } + + const url = resolveApiUrl(baseUrl, path); + const response = await fetchImpl(url, { + ...requestInit, + method, + headers + }); + cookies.absorbSetCookie(response.headers); + + if (expectOk && (response.status < 200 || response.status >= 300)) { + const body = await readJsonSafe(response.clone()); + throw createAuthError( + response.status, + body?.error?.code, + body?.error?.message ?? `Request failed with status ${response.status}` + ); + } + + return response; + } + + async function fetchJson(path, init = {}) { + const response = await authenticatedFetch(path, { ...init, expectOk: true }); + return { + response, + body: await readJsonSafe(response) + }; + } + + async function verifyCurrentUser() { + const { body } = await fetchJson("/api/v1/me"); + const user = body?.data?.user; + const workspace = body?.data?.workspace; + if (!user?.id || !workspace?.id) { + throw createAuthError(500, "INVALID_ME_RESPONSE", "GET /api/v1/me returned incomplete identity."); + } + return { user, workspace }; + } + + async function registerAndLogin(input = {}) { + const email = input.email ?? `${randomUUID()}@example.test`; + const password = input.password ?? `pw-${randomUUID()}`; + const displayName = input.displayName ?? "Authenticated Test User"; + + const registered = await fetchJson("/api/v1/auth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, displayName }) + }); + const verificationToken = registered.body?.data?.verificationToken; + if (!verificationToken) { + throw createAuthError(500, "MISSING_VERIFICATION_TOKEN", "Register response missing verificationToken."); + } + + await fetchJson("/api/v1/auth/verify-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: verificationToken }) + }); + + const loggedIn = await fetchJson("/api/v1/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + ...(input.client ? { client: input.client } : {}) + }) + }); + + const me = await verifyCurrentUser(); + const userId = me.user.id ?? loggedIn.body?.data?.user?.id; + const workspaceId = me.workspace.id ?? loggedIn.body?.data?.workspace?.id; + if (!userId || !workspaceId) { + throw createAuthError(500, "INVALID_LOGIN_IDENTITY", "Login/me did not return user and workspace ids."); + } + + return { + userId, + workspaceId, + email: me.user.email ?? email, + cookies: cookies.snapshot(), + user: me.user, + workspace: me.workspace + }; + } + + async function logout() { + await authenticatedFetch("/api/v1/auth/logout", { + method: "POST", + expectOk: true + }); + cookies.clear(); + } + + return { + cookies, + fetch: authenticatedFetch, + fetchJson, + registerAndLogin, + verifyCurrentUser, + logout + }; +} diff --git a/scripts/lib/authenticated-test-client.test.mjs b/scripts/lib/authenticated-test-client.test.mjs new file mode 100644 index 00000000..6f90696a --- /dev/null +++ b/scripts/lib/authenticated-test-client.test.mjs @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { randomUUID } from "node:crypto"; + +import { + createAuthenticatedTestClient, + resolveApiUrl +} from "./authenticated-test-client.mjs"; + +test("resolveApiUrl keeps deployment path prefix", () => { + const url = resolveApiUrl("https://example.com/datafoundry", "api/v1/me"); + assert.equal(url.href, "https://example.com/datafoundry/api/v1/me"); +}); + +test("resolveApiUrl strips search and hash from base", () => { + const url = resolveApiUrl("https://example.com/datafoundry?x=1#frag", "/api/v1/me"); + assert.equal(url.href, "https://example.com/datafoundry/api/v1/me"); +}); + +test("resolveApiUrl preserves query from relative path", () => { + const url = resolveApiUrl("https://example.com/datafoundry", "/api/v1/sessions/s1/conversation?limit=10"); + assert.equal(url.href, "https://example.com/datafoundry/api/v1/sessions/s1/conversation?limit=10"); +}); + +test("adds cookie and csrf to unsafe requests", async () => { + const calls = []; + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return new Response("{}", { status: 200 }); + } + }); + client.cookies.replace({ + df_session: "session-secret", + df_csrf: "csrf-secret" + }); + + await client.fetch("/api/v1/config", { method: "POST", body: "{}" }); + + assert.equal(calls[0].init.headers.get("cookie"), "df_session=session-secret; df_csrf=csrf-secret"); + assert.equal(calls[0].init.headers.get("x-csrf-token"), "csrf-secret"); +}); + +test("does not add csrf to safe GET requests", async () => { + const calls = []; + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async (url, init) => { + calls.push({ url, init }); + return new Response("{}", { status: 200 }); + } + }); + client.cookies.replace({ + df_session: "session-secret", + df_csrf: "csrf-secret" + }); + + await client.fetch("/api/v1/me"); + + assert.equal(calls[0].init.headers.get("cookie"), "df_session=session-secret; df_csrf=csrf-secret"); + assert.equal(calls[0].init.headers.get("x-csrf-token"), null); +}); + +test("absorbs Set-Cookie from multiple headers", async () => { + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async () => { + const headers = new Headers(); + headers.append("Set-Cookie", "df_session=session-a; Path=/; HttpOnly"); + headers.append("Set-Cookie", "df_csrf=csrf-a; Path=/"); + return new Response("{}", { status: 200, headers }); + } + }); + + await client.fetch("/api/v1/auth/login", { method: "POST", body: "{}" }); + assert.deepEqual(client.cookies.snapshot(), { + df_session: "session-a", + df_csrf: "csrf-a" + }); +}); + +test("auth errors omit cookie secrets", async () => { + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async () => + new Response(JSON.stringify({ error: { code: "UNAUTHORIZED", message: "nope" } }), { + status: 401, + headers: { "Content-Type": "application/json" } + }) + }); + client.cookies.replace({ + df_session: "session-secret", + df_csrf: "csrf-secret" + }); + + await assert.rejects( + () => client.fetch("/api/v1/me", { expectOk: true }), + (error) => { + const text = String(error); + assert.equal(error.status, 401); + assert.equal(error.code, "UNAUTHORIZED"); + assert.doesNotMatch(text, /session-secret|csrf-secret/); + return true; + } + ); +}); + +test("verifyCurrentUser calls GET /api/v1/me only", async () => { + const calls = []; + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787/datafoundry", + fetchImpl: async (url, init) => { + calls.push({ url: String(url), method: init?.method ?? "GET" }); + return new Response( + JSON.stringify({ + data: { + user: { id: "u1", email: "a@example.test" }, + workspace: { id: "w1" } + } + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + }); + client.cookies.replace({ df_session: "s", df_csrf: "c" }); + + const me = await client.verifyCurrentUser(); + assert.equal(me.user.id, "u1"); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, "http://127.0.0.1:8787/datafoundry/api/v1/me"); + assert.equal(calls[0].method, "GET"); + assert.ok(!calls.some((call) => call.url.includes("/api/v1/auth/me"))); +}); + +test("registerAndLogin walks register verify login and me", async () => { + const calls = []; + const email = `${randomUUID()}@example.test`; + const client = createAuthenticatedTestClient({ + baseUrl: "http://127.0.0.1:8787", + fetchImpl: async (url, init) => { + const href = String(url); + const method = init?.method ?? "GET"; + calls.push({ href, method, body: init?.body }); + if (href.endsWith("/api/v1/auth/register") && method === "POST") { + return new Response( + JSON.stringify({ + data: { + verificationToken: "verify-token", + user: { id: "user-1", email } + } + }), + { + status: 201, + headers: { "Content-Type": "application/json" } + } + ); + } + if (href.endsWith("/api/v1/auth/verify-email") && method === "POST") { + return new Response(JSON.stringify({ data: { ok: true } }), { + status: 200, + headers: { "Content-Type": "application/json" } + }); + } + if (href.endsWith("/api/v1/auth/login") && method === "POST") { + const headers = new Headers({ "Content-Type": "application/json" }); + headers.append("Set-Cookie", "df_session=session-1; Path=/; HttpOnly"); + headers.append("Set-Cookie", "df_csrf=csrf-1; Path=/"); + return new Response( + JSON.stringify({ + data: { + user: { id: "user-1", email }, + workspace: { id: "personal-user-1" } + } + }), + { status: 200, headers } + ); + } + if (href.endsWith("/api/v1/me") && method === "GET") { + return new Response( + JSON.stringify({ + data: { + user: { id: "user-1", email }, + workspace: { id: "personal-user-1" } + } + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ); + } + return new Response(JSON.stringify({ error: { code: "NOT_FOUND", message: href } }), { + status: 404, + headers: { "Content-Type": "application/json" } + }); + } + }); + + const result = await client.registerAndLogin({ + email, + password: "correct-horse-battery", + displayName: "Test User" + }); + + assert.equal(result.userId, "user-1"); + assert.equal(result.workspaceId, "personal-user-1"); + assert.equal(result.email, email); + assert.deepEqual(result.cookies, { + df_session: "session-1", + df_csrf: "csrf-1" + }); + assert.deepEqual( + calls.map((call) => `${call.method} ${new URL(call.href).pathname}`), + [ + "POST /api/v1/auth/register", + "POST /api/v1/auth/verify-email", + "POST /api/v1/auth/login", + "GET /api/v1/me" + ] + ); + assert.ok(!calls.some((call) => call.href.includes("/api/v1/auth/me"))); +}); diff --git a/scripts/run-dacomp6-complex-case.mjs b/scripts/run-dacomp6-complex-case.mjs index 0974b9b6..22caffd5 100644 --- a/scripts/run-dacomp6-complex-case.mjs +++ b/scripts/run-dacomp6-complex-case.mjs @@ -3,8 +3,12 @@ import assert from "node:assert/strict"; import { EventType } from "@ag-ui/client"; import { dacomp6ComplexCases, findDacomp6Case } from "./dacomp6-complex-cases.mjs"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const baseUrl = (process.env.DATAFOUNDRY_BASE_URL ?? "http://127.0.0.1:8787").replace(/\/$/u, ""); + +const client = createAuthenticatedTestClient({ baseUrl }); +await client.registerAndLogin({ displayName: "DACOMP Smoke" }); const caseId = process.argv[2] ?? "profit-root-cause"; if (caseId === "--list") { @@ -54,7 +58,7 @@ console.log(`Open in DataFoundry: http://localhost:3000/data-tasks?thread=${enco process.exit(0); async function runAgent(input) { - const response = await fetch(`${baseUrl}/api/copilotkit`, { + const response = await client.fetch("/api/copilotkit", { method: "POST", headers: requestHeaders("text/event-stream"), body: JSON.stringify({ @@ -90,8 +94,8 @@ async function runAgent(input) { async function waitForTraceSections(sessionId) { const deadline = Date.now() + 180_000; while (Date.now() < deadline) { - const response = await fetch( - `${baseUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/trace-dag`, + const response = await client.fetch( + `/api/v1/sessions/${encodeURIComponent(sessionId)}/trace-dag`, { headers: requestHeaders("application/json") }, ); if (response.ok) { @@ -125,8 +129,6 @@ function parseEventStream(text) { function requestHeaders(accept) { return { Accept: accept, - Authorization: "Bearer dev-token", "Content-Type": "application/json", - "X-Workspace-Id": "default", }; } diff --git a/scripts/seed-dtc-growth-demo.mjs b/scripts/seed-dtc-growth-demo.mjs index c5a912bf..afa07df8 100644 --- a/scripts/seed-dtc-growth-demo.mjs +++ b/scripts/seed-dtc-growth-demo.mjs @@ -7,6 +7,7 @@ import { mkdirSync, rmSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixturesRoot = resolve(repoRoot, "storage/fixtures"); @@ -217,8 +218,18 @@ function insertRows(database, table, rows) { } } +const client = createAuthenticatedTestClient({ baseUrl: apiBase }); +let authenticated = false; +async function ensureAuth() { + if (!authenticated) { + await client.registerAndLogin({ displayName: "Seed DTC Growth" }); + authenticated = true; + } +} + async function requestJson(path, init = {}) { - const response = await fetch(`${apiBase}${path}`, init); + await ensureAuth(); + const response = await client.fetch(path, init); const body = await response.json().catch(() => ({})); return { body, response }; } diff --git a/scripts/seed-local-fixtures.mjs b/scripts/seed-local-fixtures.mjs index 0e3ee880..bed7d026 100644 --- a/scripts/seed-local-fixtures.mjs +++ b/scripts/seed-local-fixtures.mjs @@ -8,6 +8,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const fixturesRoot = resolve(repoRoot, "storage/fixtures"); @@ -62,8 +63,18 @@ const datasources = [ }, ]; +const client = createAuthenticatedTestClient({ baseUrl: apiBase }); +let authenticated = false; +async function ensureAuth() { + if (!authenticated) { + await client.registerAndLogin({ displayName: "Seed Local Fixtures" }); + authenticated = true; + } +} + async function requestJson(path, init = {}) { - const response = await fetch(`${apiBase}${path}`, init); + await ensureAuth(); + const response = await client.fetch(path, init); const body = await response.json(); return { body, response }; } @@ -130,7 +141,7 @@ async function verifyDatasource(id) { } try { - const health = await fetch(`${apiBase}/healthz`); + const health = await fetch(`${apiBase}/healthz`); // public if (!health.ok) { throw new Error(`API not reachable at ${apiBase} — start with: npm run dev:api`); } diff --git a/scripts/smoke-agent-protocol-deepseek.mjs b/scripts/smoke-agent-protocol-deepseek.mjs index 32a5a94e..de3f1726 100644 --- a/scripts/smoke-agent-protocol-deepseek.mjs +++ b/scripts/smoke-agent-protocol-deepseek.mjs @@ -6,6 +6,7 @@ import { createRunProtocolBoundary } from "../packages/agent-runtime/dist/testing.js"; import { createModelProvider } from "../packages/providers/dist/index.js"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const provider = createModelProvider(process.env); assert.notEqual(provider.kind, "mock", "DeepSeek credentials are required; fake LLM is not allowed"); @@ -139,14 +140,22 @@ async function runApiScenarios(baseUrl) { ); } + +const clientsByBase = new Map(); +async function createClientForBase(baseUrl) { + const client = createAuthenticatedTestClient({ baseUrl }); + await client.registerAndLogin({ displayName: "Deepseek Protocol Smoke" }); + clientsByBase.set(baseUrl, client); + return client; +} + async function runAgent(baseUrl, input) { - const response = await fetch(`${baseUrl}/api/copilotkit`, { + const client = clientsByBase.get(baseUrl) ?? await createClientForBase(baseUrl); + const response = await client.fetch("/api/copilotkit", { method: "POST", headers: { Accept: "text/event-stream", - Authorization: "Bearer dev-token", - "Content-Type": "application/json", - "X-Workspace-Id": "default" + "Content-Type": "application/json" }, body: JSON.stringify({ method: "agent/run", diff --git a/scripts/smoke-ask-user-interrupt.mjs b/scripts/smoke-ask-user-interrupt.mjs index 404ee403..da4099d9 100644 --- a/scripts/smoke-ask-user-interrupt.mjs +++ b/scripts/smoke-ask-user-interrupt.mjs @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const envPath = join(process.cwd(), ".env"); try { @@ -26,6 +27,8 @@ if (!process.env.LLM_API_KEY) { } const apiBase = `http://127.0.0.1:${process.env.API_PORT ?? "8787"}`; +const client = createAuthenticatedTestClient({ baseUrl: apiBase }); +await client.registerAndLogin({ displayName: "Ask User Smoke" }); const threadId = `thread-ask-smoke-${Date.now()}`; const runId = randomUUID(); @@ -50,7 +53,7 @@ const payload = { }, }; -const response = await fetch(`${apiBase}/api/copilotkit`, { +const response = await client.fetch("/api/copilotkit", { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, body: JSON.stringify(payload), diff --git a/scripts/smoke-auth.mjs b/scripts/smoke-auth.mjs index 7d972755..7992f20a 100644 --- a/scripts/smoke-auth.mjs +++ b/scripts/smoke-auth.mjs @@ -5,12 +5,14 @@ import { join } from "node:path"; 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 root = mkdtempSync(join(tmpdir(), "datafoundry-auth-smoke-")); process.env.DATAFOUNDRY_AUTH_MODE = "password"; process.env.AUTH_SESSION_SECRET = "smoke-session-secret-with-at-least-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; @@ -32,33 +34,10 @@ const closeServer = async () => { }); }; -const cookieJar = []; -const rememberCookies = (response) => { - const setCookie = response.headers.getSetCookie?.() ?? []; - for (const cookie of setCookie) { - const pair = cookie.split(";", 1)[0]; - const name = pair.split("=", 1)[0]; - const index = cookieJar.findIndex((item) => item.startsWith(`${name}=`)); - if (index >= 0) { - cookieJar.splice(index, 1, pair); - } else { - cookieJar.push(pair); - } - } -}; -const cookieHeader = () => cookieJar.join("; "); -const csrfCookie = () => { - const entry = cookieJar.find((item) => item.startsWith("df_csrf=")); - return entry ? decodeURIComponent(entry.slice("df_csrf=".length)) : undefined; -}; +const client = createAuthenticatedTestClient({ baseUrl }); const requestJson = async (path, init = {}) => { - const headers = { - ...(cookieJar.length > 0 ? { Cookie: cookieHeader() } : {}), - ...init.headers - }; - const response = await fetch(`${baseUrl}${path}`, { ...init, headers }); - rememberCookies(response); + const response = await client.fetch(path, init); const body = await response.json(); return { body, response }; }; @@ -140,7 +119,7 @@ try { const missingCsrf = await requestJson("/api/v1/datasources", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "X-CSRF-Token": "" }, body: JSON.stringify({ id: "alice-sqlite", name: "Alice SQLite", @@ -148,14 +127,15 @@ try { settings: { filePath: join(root, "alice.sqlite") } }) }); + // Clear empty override so subsequent unsafe calls auto-attach cookie CSRF. assert.equal(missingCsrf.response.status, 403); - assert.equal(missingCsrf.body.error.code, "FORBIDDEN"); + assert.equal(missingCsrf.body.error.code, "CSRF_INVALID"); - const csrf = csrfCookie(); + const csrf = client.cookies.csrfToken(); assert(csrf, "Expected login to set a readable CSRF cookie"); const aliceDatasource = await requestJson("/api/v1/datasources", { method: "POST", - headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "alice-sqlite", name: "Alice SQLite", @@ -186,7 +166,7 @@ try { const revokedMe = await requestJson("/api/v1/me"); assert.equal(revokedMe.response.status, 401); - cookieJar.splice(0, cookieJar.length); + client.cookies.clear(); const relogged = await requestJson("/api/v1/auth/login", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -195,8 +175,7 @@ try { assert.equal(relogged.response.status, 200); const logout = await requestJson("/api/v1/auth/logout", { - method: "POST", - headers: { "X-CSRF-Token": csrfCookie() } + method: "POST" }); assert.equal(logout.response.status, 200); diff --git a/scripts/smoke-config-api.mjs b/scripts/smoke-config-api.mjs index 1f35b7b5..33b890f2 100644 --- a/scripts/smoke-config-api.mjs +++ b/scripts/smoke-config-api.mjs @@ -14,6 +14,7 @@ import { resolveEffectiveRunConfig } from "../apps/api/dist/run-input.js"; import { createTaskStateRuntime } from "../packages/agent-runtime/dist/index.js"; import { LocalDataGateway } from "../packages/data-gateway/dist/index.js"; import { RunEventWriter, createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const root = mkdtempSync(join(tmpdir(), "open-data-foundry-config-smoke-")); const datasourcePath = join(root, "source.sqlite"); @@ -26,6 +27,11 @@ source.exec(` `); source.close(); +process.env.DATAFOUNDRY_AUTH_MODE = "password"; +process.env.AUTH_SESSION_SECRET = "config-smoke-session-secret-32bytes!!"; +process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1:3000"; +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; @@ -131,20 +137,21 @@ 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}`; -metadataStore.users.upsertDevUser({ - id: "tenant-user", - email: "tenant@example.com", - display_name: "Tenant User", - dev_token: "tenant-token" -}); -const requestJson = async (path, init = {}) => { - const response = await fetch(`${baseUrl}${path}`, init); +const client = createAuthenticatedTestClient({ baseUrl }); +const identity = await client.registerAndLogin({ displayName: "Config Smoke User" }); +const { userId, workspaceId } = identity; + +const tenantClient = createAuthenticatedTestClient({ baseUrl }); +const tenantIdentity = await tenantClient.registerAndLogin({ displayName: "Tenant User" }); + +const requestJson = async (path, init = {}, authClient = client) => { + const response = await authClient.fetch(path, init); const body = await response.json(); return { body, response }; }; -const requestRaw = async (path, init = {}) => fetch(`${baseUrl}${path}`, init); +const requestRaw = async (path, init = {}, authClient = client) => authClient.fetch(path, init); const closeHttpServer = async (httpServer) => { await new Promise((resolve, reject) => { @@ -207,78 +214,55 @@ try { ); } - const invalidToken = await requestJson("/api/v1/workspace-config", { - headers: { "Authorization": "Bearer invalid-token" } - }); - assert.equal(invalidToken.response.status, 401); - assert.equal(invalidToken.body.error.code, "UNAUTHORIZED"); + const anonymousClient = createAuthenticatedTestClient({ baseUrl }); + const unauthenticated = await requestJson("/api/v1/workspace-config", {}, anonymousClient); + assert.equal(unauthenticated.response.status, 401); + assert.equal(unauthenticated.body.error.code, "UNAUTHORIZED"); const defaultMe = await requestJson("/api/v1/me"); assert.equal(defaultMe.response.status, 200); - assert.equal(defaultMe.body.data.user.id, "dev-user"); - assert.equal(defaultMe.body.data.workspace.id, "default"); - - const identitiesBeforeCreate = await requestJson("/api/v1/dev/identities"); - assert.equal(identitiesBeforeCreate.response.status, 200); - assert(identitiesBeforeCreate.body.data.users.some((user) => user.id === "dev-user")); + assert.equal(defaultMe.body.data.user.id, userId); + assert.equal(defaultMe.body.data.workspace.id, workspaceId); - const createdDevUser = await requestJson("/api/v1/dev/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: "analyst-user", - email: "analyst@example.com", - displayName: "Analyst User" - }) - }); - assert.equal(createdDevUser.response.status, 201, JSON.stringify(createdDevUser.body)); - assert.equal(createdDevUser.body.data.user.id, "analyst-user"); - assert.equal(createdDevUser.body.data.user.devToken, "dev-token-analyst-user"); - const analystMe = await requestJson("/api/v1/me", { - headers: { "Authorization": "Bearer dev-token-analyst-user" } + const analystClient = createAuthenticatedTestClient({ baseUrl }); + const analystIdentity = await analystClient.registerAndLogin({ + email: `analyst-${Date.now()}@example.test`, + displayName: "Analyst User" }); + const analystMe = await requestJson("/api/v1/me", {}, analystClient); assert.equal(analystMe.response.status, 200); - assert.equal(analystMe.body.data.user.id, "analyst-user"); - assert.equal(analystMe.body.data.workspace.id, "default"); + assert.equal(analystMe.body.data.user.id, analystIdentity.userId); + assert.equal(analystMe.body.data.workspace.id, analystIdentity.workspaceId); + assert.notEqual(analystIdentity.userId, userId); - const tenantHeaders = { - "Authorization": "Bearer tenant-token", - "Content-Type": "application/json", - "X-Workspace-Id": "tenant-workspace" - }; const tenantDatasource = await requestJson("/api/v1/datasources", { method: "POST", - headers: tenantHeaders, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "local-sqlite", name: "Tenant SQLite", type: "sqlite", settings: { filePath: datasourcePath } }) - }); + }, tenantClient); assert.equal(tenantDatasource.response.status, 201, JSON.stringify(tenantDatasource.body)); const devDatasourceListBeforeTenant = await requestJson("/api/v1/datasources"); assert.equal(devDatasourceListBeforeTenant.body.data.some((item) => item.id === "local-sqlite"), false); const tenantKnowledge = await requestJson("/api/v1/knowledge-bases", { method: "POST", - headers: tenantHeaders, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: "tenant-docs", name: "Tenant Docs", defaultEnabled: true, retrievalTopK: 2 }) - }); + }, tenantClient); assert.equal(tenantKnowledge.response.status, 201); const devTenantKnowledge = await requestJson("/api/v1/knowledge-bases/tenant-docs"); assert.equal(devTenantKnowledge.response.status, 404); - const tenantOtherWorkspaceKnowledge = await requestJson("/api/v1/knowledge-bases/tenant-docs", { - headers: { - "Authorization": "Bearer tenant-token", - "X-Workspace-Id": "other-workspace" - } - }); - assert.equal(tenantOtherWorkspaceKnowledge.response.status, 404); + const tenantVisibleKnowledge = await requestJson("/api/v1/knowledge-bases/tenant-docs", {}, tenantClient); + assert.equal(tenantVisibleKnowledge.response.status, 200); const uploadForm = new FormData(); uploadForm.append("sessionId", "session-smoke-upload"); @@ -324,9 +308,9 @@ try { const conversationSessionId = "conversation-api-session"; const conversationRunId = "conversation-api-run"; - metadataStore.sessions.create({ user_id: "dev-user", id: conversationSessionId, title: "conversation API" }); + metadataStore.sessions.create({ user_id: userId, id: conversationSessionId, title: "conversation API" }); metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: conversationRunId, session_id: conversationSessionId, request_fingerprint: "conversation-api-fingerprint", @@ -334,7 +318,7 @@ try { status: "completed" }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: conversationRunId, id: `${conversationRunId}:user`, @@ -345,7 +329,7 @@ try { content: { text: "inspect orders" } }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: conversationRunId, id: `${conversationRunId}:assistant`, @@ -356,7 +340,7 @@ try { content: { text: "orders has 2 columns" } }); metadataStore.conversationSummaries.create({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, id: `summary:${conversationSessionId}:1-1`, source_run_id: conversationRunId, @@ -366,19 +350,19 @@ try { }); const conversationWriter = new RunEventWriter(metadataStore.runEvents); conversationWriter.write({ - user_id: "dev-user", + user_id: userId, run_id: conversationRunId, session_id: conversationSessionId, event: { type: "TOOL_CALL_START", toolCallId: "call_schema", toolCallName: "inspect_schema" } }); conversationWriter.write({ - user_id: "dev-user", + user_id: userId, run_id: conversationRunId, session_id: conversationSessionId, event: { type: "TOOL_CALL_END", toolCallId: "call_schema", toolCallName: "inspect_schema" } }); conversationWriter.write({ - user_id: "dev-user", + user_id: userId, run_id: conversationRunId, session_id: conversationSessionId, event: { @@ -442,7 +426,7 @@ try { // R-018: pending HITL without TOOL_CALL_START still synthesizes toolCalls + checkpoint. const hitlOnlyRunId = "conversation-hitl-only-run"; metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: hitlOnlyRunId, session_id: conversationSessionId, user_input: "ask the user", @@ -450,7 +434,7 @@ try { }); metadataStore.interactions.request({ id: "interaction-hitl-only", - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: hitlOnlyRunId, tool_call_id: "call_ask_missing_start", @@ -487,14 +471,14 @@ try { const branchOriginalRunId = "conversation-branch-original-run"; const branchPendingRunId = "conversation-branch-pending-run"; metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: branchBaseRunId, session_id: conversationSessionId, user_input: "summarize orders", status: "completed" }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: branchBaseRunId, id: `${branchBaseRunId}:user`, @@ -505,7 +489,7 @@ try { content: { text: "summarize orders" } }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: branchBaseRunId, id: `${branchBaseRunId}:assistant`, @@ -516,14 +500,14 @@ try { content: { text: "orders summary" } }); metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: branchOriginalRunId, session_id: conversationSessionId, user_input: "make a chart", status: "completed" }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: branchOriginalRunId, id: `${branchOriginalRunId}:user`, @@ -534,7 +518,7 @@ try { content: { text: "make a chart" } }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: conversationSessionId, run_id: branchOriginalRunId, id: `${branchOriginalRunId}:assistant`, @@ -545,7 +529,7 @@ try { content: { text: "original chart answer" } }); metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: branchPendingRunId, session_id: conversationSessionId, user_input: "still running", @@ -573,7 +557,7 @@ try { const branchRunId = "conversation-branch-child-run"; metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: branchRunId, session_id: branchSessionId, parent_run_id: branchOriginalRunId, @@ -581,7 +565,7 @@ try { status: "completed" }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: branchSessionId, run_id: branchRunId, id: `${branchRunId}:user`, @@ -592,7 +576,7 @@ try { content: { text: "make a table instead" } }); metadataStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: branchSessionId, run_id: branchRunId, id: `${branchRunId}:assistant`, @@ -711,12 +695,18 @@ try { const policyGateway = new LocalDataGateway(metadataStore, { defaultLimit: 100, maxLimit: 1000, - timeoutMs: 10000 + timeoutMs: 10000, + workspaceId + }); + const policySchema = await policyGateway.inspectSchema({ + user_id: userId, + workspace_id: workspaceId, + datasource_id: "local-sqlite" }); - const policySchema = await policyGateway.inspectSchema({ user_id: "dev-user", datasource_id: "local-sqlite" }); assert.deepEqual(policySchema.tables.map((table) => table.name), ["metrics"]); const preview = await policyGateway.previewTable({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: "local-sqlite", table: "metrics", limit: 10 @@ -724,7 +714,8 @@ try { assert.equal(preview.row_count, 1, "samplePolicy.maxSampleRows should cap preview rows"); assert.equal(preview.rows[0]?.[preview.columns.indexOf("secret")], "[MASKED]"); const maskedSql = await policyGateway.runSqlReadonly({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: "local-sqlite", sql: "SELECT name, secret FROM metrics", limit: 10 @@ -732,7 +723,8 @@ try { assert.equal(maskedSql.rows[0]?.[maskedSql.columns.indexOf("secret")], "[MASKED]"); await assert.rejects( () => policyGateway.previewTable({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: "local-sqlite", table: "private_metrics", limit: 1 @@ -741,7 +733,8 @@ try { ); await assert.rejects( () => policyGateway.runSqlReadonly({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: "local-sqlite", sql: "SELECT * FROM private_metrics", limit: 1 @@ -759,7 +752,8 @@ try { assert.equal(sampleDisabledPatch.response.status, 200); await assert.rejects( () => policyGateway.previewTable({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: "local-sqlite", table: "metrics", limit: 1 @@ -880,7 +874,7 @@ try { // Light upload → promote → list(scope=workspace) path for workspace assets. const workspaceSessionId = "config-smoke-workspace-promote"; metadataStore.sessions.create({ - user_id: "dev-user", + user_id: userId, id: workspaceSessionId, title: "config smoke workspace promote" }); @@ -1106,9 +1100,9 @@ try { } } }, - userId: "dev-user", + userId: userId, userInput: "inspect metrics", - workspaceId: "default" + workspaceId }); assert.equal(resolvedModelRun.modelSettings?.topP, 0.75); assert.equal(resolvedModelRun.modelSettings?.frequencyPenalty, 0.25); @@ -1138,9 +1132,9 @@ try { } } }, - userId: "dev-user", + userId: userId, userInput: "use smoke skill to inspect schema", - workspaceId: "default" + workspaceId }); assert.equal(skillBoundRun.effectiveRunConfig.activeDatasourceId, "local-sqlite"); assert.equal(skillBoundRun.effectiveRunConfig.activeLlmProfileId, "smoke-openai-compatible"); @@ -1152,20 +1146,20 @@ try { assert.deepEqual(skillBoundRun.mcpRuntime.servers[0]?.toolAllowlist, ["echo"]); const currentMetricsDocsResource = metadataStore.configResources.get({ id: "metrics-docs", - workspace_id: "default", - user_id: "dev-user", + workspace_id: workspaceId, + user_id: userId, kind: "knowledge-base" }); const currentMcpResource = metadataStore.configResources.get({ id: "smoke-mcp", - workspace_id: "default", - user_id: "dev-user", + workspace_id: workspaceId, + user_id: userId, kind: "mcp-server" }); const currentModelProfileResource = metadataStore.configResources.get({ id: "smoke-openai-compatible", - workspace_id: "default", - user_id: "dev-user", + workspace_id: workspaceId, + user_id: userId, kind: "model-profile" }); assert.equal( @@ -1203,9 +1197,9 @@ try { } } }, - userId: "dev-user", + userId: userId, userInput: "use smoke skill to inspect schema", - workspaceId: "default" + workspaceId }); assert.equal(explicitResourceRun.effectiveRunConfig.activeDatasourceId, "dtc-growth-demo"); assert(explicitResourceRun.effectiveRunConfig.enabledDatasourceIds.includes("local-sqlite")); @@ -1269,7 +1263,7 @@ try { }], state: {}, forwardedProps: {} - }, metadataStore, "dev-user", "dtc-growth-demo"); + }, metadataStore, userId, "dtc-growth-demo", workspaceId); assert.equal(effective.activeSkillId, undefined); assert.equal(effective.resourceRevisions["datasource:local-sqlite"], sampleDisabledPatch.body.data.revision); assert.equal(effective.resourceRevisions["model-profile:server-default"] > 0, true); @@ -1290,9 +1284,9 @@ try { const defaults = await requestJson("/api/v1/run-defaults"); assert.equal(defaults.body.data.enabledKnowledgeIds.includes("metrics-docs"), false); - metadataStore.sessions.create({ user_id: "dev-user", id: "session-smoke", title: "config smoke" }); + metadataStore.sessions.create({ user_id: userId, id: "session-smoke", title: "config smoke" }); metadataStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-smoke", session_id: "session-smoke", request_fingerprint: "config-smoke", @@ -1300,7 +1294,7 @@ try { status: "completed" }); metadataStore.contextPackageSnapshots.create({ - user_id: "dev-user", + user_id: userId, session_id: "session-smoke", run_id: "run-smoke", package_id: "context-smoke", @@ -1308,7 +1302,7 @@ try { payload: {} }); metadataStore.protocolStates.compareAndSetWithEvents({ - user_id: "dev-user", + user_id: userId, run_id: "run-smoke", segment_id: "segment-smoke", expected_revision: -1, @@ -1333,12 +1327,12 @@ try { revision: 0 }]); assert.equal(metadataStore.protocolStates.pendingEvents({ - user_id: "dev-user", + user_id: userId, run_id: "run-smoke" }).length, 1); const artifact = metadataStore.artifacts.create({ id: "artifact-smoke", - user_id: "dev-user", + user_id: userId, session_id: "session-smoke", run_id: "run-smoke", type: "table", @@ -1346,7 +1340,7 @@ try { preview_json: { columns: ["name", "value"], rows: [{ name: "revenue", value: 42 }] } }); assert.equal(artifact.id, "artifact-smoke"); - const download = await fetch(`${baseUrl}/api/v1/artifacts/artifact-smoke/download`); + const download = await client.fetch("/api/v1/artifacts/artifact-smoke/download"); assert.equal(download.headers.get("content-type"), "text/csv; charset=utf-8"); assert.equal((await download.text()).includes("revenue,42"), true); @@ -1358,12 +1352,12 @@ try { deletedSessionIds: ["session-smoke"] }); assert.equal(metadataStore.protocolStates.find({ - user_id: "dev-user", + user_id: userId, run_id: "run-smoke", segment_id: "segment-smoke" }), undefined); assert.deepEqual(metadataStore.protocolStates.pendingEvents({ - user_id: "dev-user", + user_id: userId, run_id: "run-smoke" }), []); @@ -1372,7 +1366,7 @@ try { assert.equal(builtinDelete.body.data.deleted, true); metadataStore.sqlAuditLogs.create({ - user_id: "dev-user", + user_id: userId, id: "audit-before-delete", datasource_id: "local-sqlite", sql_text: "SELECT 1", @@ -1384,8 +1378,8 @@ try { assert.equal(datasourceList.body.data.some((item) => item.id === "local-sqlite"), false); assert.throws(() => metadataStore.secrets.get({ ref: createdDatasource.body.data.secretRef, - workspace_id: "default", - user_id: "dev-user" + workspace_id: workspaceId, + user_id: userId }), /SECRET_NOT_FOUND/u); console.log( diff --git a/scripts/smoke-copilotkit-run.mjs b/scripts/smoke-copilotkit-run.mjs index 48adf617..58b00836 100644 --- a/scripts/smoke-copilotkit-run.mjs +++ b/scripts/smoke-copilotkit-run.mjs @@ -6,12 +6,18 @@ import { join } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { EventType } from "@ag-ui/core"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const root = mkdtempSync(join(tmpdir(), "open-data-foundry-copilotkit-run-")); const metadataPath = join(root, "metadata.sqlite"); const mastraStoragePath = join(root, "mastra.sqlite"); const workspaceRoot = join(root, "workspaces"); +process.env.DATAFOUNDRY_AUTH_MODE = "password"; +process.env.AUTH_SESSION_SECRET = "copilotkit-run-session-secret-32b!!!"; +process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1:3000"; +process.env.AUTH_EMAIL_DELIVERY = "test"; +process.env.AUTH_REGISTRATION_MODE = "open"; process.env.EMBEDDING_API_KEY = ""; process.env.LLM_PROVIDER = "openai-compatible"; process.env.LLM_MODEL = "copilotkit-smoke-model"; @@ -142,7 +148,30 @@ const address = server.address(); assert(address && typeof address === "object"); const baseUrl = `http://127.0.0.1:${address.port}`; +const client = createAuthenticatedTestClient({ baseUrl }); +const identity = await client.registerAndLogin({ displayName: "CopilotKit Run Smoke" }); +const { userId } = identity; + try { + const demoDbPath = join(root, "api-duckdb-demo.sqlite"); + const demoDb = new DatabaseSync(demoDbPath); + demoDb.exec(` + CREATE TABLE orders (id INTEGER, amount REAL); + INSERT INTO orders VALUES (1, 10.5), (2, 20); + `); + demoDb.close(); + const demoDatasource = await client.fetch("/api/v1/datasources", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: "api-duckdb-demo", + name: "CopilotKit Smoke Demo", + type: "sqlite", + settings: { filePath: demoDbPath } + }) + }); + assert.equal(demoDatasource.status, 201, await demoDatasource.text()); + const skillForm = new FormData(); skillForm.set("file", new Blob([ "---\n", @@ -154,12 +183,12 @@ try { "---\n", "Use this skill to prove Mastra skill tool loading works in the AG-UI runtime.\n" ], { type: "text/markdown" }), "SKILL.md"); - const skillUploadResponse = await fetch(`${baseUrl}/api/v1/skills`, { method: "POST", body: skillForm }); + const skillUploadResponse = await client.fetch("/api/v1/skills", { method: "POST", body: skillForm }); assert.equal(skillUploadResponse.status, 201); const skillUpload = await skillUploadResponse.json(); assert.equal(skillUpload.data.validationStatus, "valid"); - const timeoutProfileResponse = await fetch(`${baseUrl}/api/v1/model-profiles`, { + const timeoutProfileResponse = await client.fetch("/api/v1/model-profiles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -204,8 +233,8 @@ try { "Missing model profile should emit RUN_ERROR" ); assertRunStatusDelta(badModelEvents, "failed"); - const badModelConversationResponse = await fetch( - `${baseUrl}/api/v1/sessions/${badModelThreadId}/conversation?limit=10` + const badModelConversationResponse = await client.fetch( + `/api/v1/sessions/${badModelThreadId}/conversation?limit=10` ); assert.equal(badModelConversationResponse.status, 200); const badModelConversation = await badModelConversationResponse.json(); @@ -270,19 +299,19 @@ try { const branchSeedStore = createMetadataStore({ database_path: metadataPath }); try { branchSeedStore.sessions.create({ - user_id: "dev-user", + user_id: userId, id: branchParentSessionId, title: "CopilotKit branch smoke" }); branchSeedStore.runs.create({ - user_id: "dev-user", + user_id: userId, id: branchParentRunId, session_id: branchParentSessionId, user_input: "请分析订单。", status: "completed" }); branchSeedStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: branchParentSessionId, run_id: branchParentRunId, id: `${branchParentRunId}:user`, @@ -293,7 +322,7 @@ try { content: { text: "请分析订单。" } }); branchSeedStore.conversationMessages.append({ - user_id: "dev-user", + user_id: userId, session_id: branchParentSessionId, run_id: branchParentRunId, id: `${branchParentRunId}:assistant`, @@ -304,13 +333,13 @@ try { content: { text: "订单分析已完成。" } }); branchSeedStore.sessions.touchLastMessage({ - user_id: "dev-user", + user_id: userId, session_id: branchParentSessionId }); } finally { branchSeedStore.close(); } - const branchResponse = await fetch(`${baseUrl}/api/v1/sessions/${branchParentSessionId}/branches`, { + const branchResponse = await client.fetch(`/api/v1/sessions/${branchParentSessionId}/branches`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ runId: branchParentRunId }) @@ -421,7 +450,7 @@ try { const store = createMetadataStore({ database_path: metadataPath }); try { - const persisted = store.runEvents.listByRun({ user_id: "dev-user", run_id: runId }); + const persisted = store.runEvents.listByRun({ user_id: userId, run_id: runId }); assert(persisted.length > 0, "DataFoundryAgUiAgent.run should persist AG-UI events"); const persistedEvents = persisted.map((item) => JSON.parse(item.payload_json)); assert.equal(persistedEvents[persistedEvents.length - 1]?.type, EventType.RUN_FINISHED); @@ -477,10 +506,10 @@ try { const suspendedStore = createMetadataStore({ database_path: metadataPath }); try { - const suspendedRun = suspendedStore.runs.get({ user_id: "dev-user", run_id: suspendedRunId }); + const suspendedRun = suspendedStore.runs.get({ user_id: userId, run_id: suspendedRunId }); assert.equal(suspendedRun.status, "suspended"); const suspendedPersistedEvents = suspendedStore.runEvents - .listByRun({ user_id: "dev-user", run_id: suspendedRunId }) + .listByRun({ user_id: userId, run_id: suspendedRunId }) .map((item) => JSON.parse(item.payload_json)); assert.equal( suspendedPersistedEvents.some((event) => event.type === EventType.RUN_FINISHED), @@ -491,7 +520,7 @@ try { ); assertRunStatusDelta(suspendedPersistedEvents, "suspended"); const suspendedMessages = suspendedStore.conversationMessages.listRecent({ - user_id: "dev-user", + user_id: userId, session_id: suspendedThreadId, limit: 20 }); @@ -535,8 +564,8 @@ function writeStreamDone(response, model, finishReason) { response.end("data: [DONE]\n\n"); } -async function runCopilotKitAgent(baseUrl, input) { - return readAgUiEventStream(await fetch(`${baseUrl}/api/copilotkit`, { +async function runCopilotKitAgent(_baseUrl, input) { + return readAgUiEventStream(await client.fetch("/api/copilotkit", { method: "POST", headers: { "Accept": "text/event-stream", @@ -550,8 +579,8 @@ async function runCopilotKitAgent(baseUrl, input) { })); } -async function connectCopilotKitAgent(baseUrl, threadId) { - return readAgUiEventStream(await fetch(`${baseUrl}/api/copilotkit`, { +async function connectCopilotKitAgent(_baseUrl, threadId) { + return readAgUiEventStream(await client.fetch("/api/copilotkit", { method: "POST", headers: { "Accept": "text/event-stream", diff --git a/scripts/smoke-copilotkit.mjs b/scripts/smoke-copilotkit.mjs index b0089e07..076e3ba3 100644 --- a/scripts/smoke-copilotkit.mjs +++ b/scripts/smoke-copilotkit.mjs @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { setTimeout as delay } from "node:timers/promises"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const apiPort = process.env.API_PORT ?? "8798"; const apiBaseUrl = `http://127.0.0.1:${apiPort}`; @@ -10,7 +11,12 @@ const child = spawn("npm", ["--workspace", "@datafoundry/api", "run", "dev"], { ...process.env, API_HOST: "127.0.0.1", API_PORT: apiPort, - METADATA_DB_PATH: metadataDbPath + METADATA_DB_PATH: metadataDbPath, + DATAFOUNDRY_AUTH_MODE: "password", + AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET ?? "copilotkit-smoke-session-secret-32b!", + AUTH_PUBLIC_BASE_URL: process.env.AUTH_PUBLIC_BASE_URL ?? "http://127.0.0.1:3000", + AUTH_EMAIL_DELIVERY: process.env.AUTH_EMAIL_DELIVERY ?? "test", + AUTH_REGISTRATION_MODE: process.env.AUTH_REGISTRATION_MODE ?? "open" }, stdio: ["ignore", "pipe", "pipe"] }); @@ -25,8 +31,10 @@ child.stderr.on("data", (chunk) => { try { await waitForHealth(apiBaseUrl); + const client = createAuthenticatedTestClient({ baseUrl: apiBaseUrl }); + await client.registerAndLogin({ displayName: "CopilotKit Smoke" }); - const optionsResponse = await fetch(`${apiBaseUrl}/api/copilotkit`, { + const optionsResponse = await client.fetch("/api/copilotkit", { method: "OPTIONS", headers: { Origin: "http://127.0.0.1:3000", @@ -38,7 +46,7 @@ try { throw new Error(`Unexpected CopilotKit OPTIONS status: ${optionsResponse.status}`); } - const postResponse = await fetch(`${apiBaseUrl}/api/copilotkit`, { + const postResponse = await client.fetch("/api/copilotkit", { method: "POST", headers: { "Content-Type": "application/json" diff --git a/scripts/smoke-interaction-run-id.mjs b/scripts/smoke-interaction-run-id.mjs index 7b845d60..62bea0e7 100644 --- a/scripts/smoke-interaction-run-id.mjs +++ b/scripts/smoke-interaction-run-id.mjs @@ -4,6 +4,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const envPath = join(process.cwd(), ".env"); try { @@ -26,6 +27,8 @@ if (!process.env.LLM_API_KEY) { } const apiBase = `http://127.0.0.1:${process.env.API_PORT ?? "8787"}`; +const client = createAuthenticatedTestClient({ baseUrl: apiBase }); +await client.registerAndLogin({ displayName: "Interaction RunId Smoke" }); const threadId = `thread-run-id-smoke-${Date.now()}`; const originalRunId = randomUUID(); const mismatchedResumeRunId = randomUUID(); @@ -116,7 +119,7 @@ if (runErrors.length > 0) { console.log("interaction run-id smoke OK"); async function runAgent(body) { - const response = await fetch(`${apiBase}/api/copilotkit`, { + const response = await client.fetch("/api/copilotkit", { method: "POST", headers: { "Content-Type": "application/json", Accept: "text/event-stream" }, body: JSON.stringify({ diff --git a/scripts/smoke-password-frontend-isolation.mjs b/scripts/smoke-password-frontend-isolation.mjs index 8d1580e7..3521981f 100644 --- a/scripts/smoke-password-frontend-isolation.mjs +++ b/scripts/smoke-password-frontend-isolation.mjs @@ -7,74 +7,14 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const baseUrl = process.env.SMOKE_BASE_URL ?? "http://localhost:3000"; -const password = "correct horse battery staple"; - -const cookieJar = []; -const rememberCookies = (response) => { - const setCookie = response.headers.getSetCookie?.() ?? []; - for (const cookie of setCookie) { - const pair = cookie.split(";", 1)[0]; - const name = pair.split("=", 1)[0]; - const index = cookieJar.findIndex((item) => item.startsWith(`${name}=`)); - if (index >= 0) cookieJar.splice(index, 1, pair); - else cookieJar.push(pair); - } -}; -const cookieHeader = () => cookieJar.join("; "); -const csrfCookie = () => { - const entry = cookieJar.find((item) => item.startsWith("df_csrf=")); - return entry ? decodeURIComponent(entry.slice("df_csrf=".length)) : undefined; -}; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; -const requestJson = async (path, init = {}) => { - const headers = { - ...(cookieJar.length > 0 ? { Cookie: cookieHeader() } : {}), - ...init.headers, - }; - const response = await fetch(`${baseUrl}${path}`, { ...init, headers }); - rememberCookies(response); - const text = await response.text(); - let body; - try { - body = text ? JSON.parse(text) : null; - } catch { - throw new Error(`Non-JSON ${response.status} for ${path}: ${text.slice(0, 200)}`); - } - return { body, response }; -}; +const baseUrl = process.env.SMOKE_BASE_URL ?? "http://localhost:3000"; -const registerVerifyLogin = async (email, displayName) => { - cookieJar.splice(0, cookieJar.length); - const registered = await requestJson("/api/v1/auth/register", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password, displayName }), - }); - assert.equal(registered.response.status, 201, JSON.stringify(registered.body)); - const token = registered.body.data.verificationToken; - assert.equal(typeof token, "string"); - const verified = await requestJson("/api/v1/auth/verify-email", { +const createDatasource = async (client, id, name, filePath) => + client.fetchJson("/api/v1/datasources", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token }), - }); - assert.equal(verified.response.status, 200, JSON.stringify(verified.body)); - const loggedIn = await requestJson("/api/v1/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - assert.equal(loggedIn.response.status, 200, JSON.stringify(loggedIn.body)); - return { ...loggedIn.body.data, email }; -}; - -const createDatasource = async (id, name, filePath) => { - const csrf = csrfCookie(); - assert(csrf, "Expected CSRF cookie after login"); - return requestJson("/api/v1/datasources", { - method: "POST", - headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, body: JSON.stringify({ id, name, @@ -82,72 +22,79 @@ const createDatasource = async (id, name, filePath) => { settings: { filePath }, }), }); -}; -const patchSessionTitle = async (sessionId, title) => { - const csrf = csrfCookie(); - return requestJson(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { +const patchSessionTitle = async (client, sessionId, title) => + client.fetchJson(`/api/v1/sessions/${encodeURIComponent(sessionId)}`, { method: "PATCH", - headers: { "Content-Type": "application/json", "X-CSRF-Token": csrf }, + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title }), }); -}; const stamp = Date.now(); -const aliceEmail = `alice-front-${stamp}@example.com`; -const bobEmail = `bob-front-${stamp}@example.com`; const root = mkdtempSync(join(tmpdir(), "df-password-front-")); const aliceDb = join(root, "alice.sqlite"); const bobDb = join(root, "bob.sqlite"); try { - const alice = await registerVerifyLogin(aliceEmail, "Alice Front"); - const aliceDs = await createDatasource("alice-front-db", "Alice Front DB", aliceDb); + const aliceClient = createAuthenticatedTestClient({ baseUrl }); + const alice = await aliceClient.registerAndLogin({ + email: `alice-front-${stamp}@example.com`, + displayName: "Alice Front", + }); + const aliceDs = await createDatasource(aliceClient, "alice-front-db", "Alice Front DB", aliceDb); assert.equal(aliceDs.response.status, 201, JSON.stringify(aliceDs.body)); const aliceSessionId = crypto.randomUUID(); - const aliceTitle = await patchSessionTitle(aliceSessionId, "Alice isolated session"); + const aliceTitle = await patchSessionTitle(aliceClient, aliceSessionId, "Alice isolated session"); assert.equal(aliceTitle.response.status, 200, JSON.stringify(aliceTitle.body)); - const aliceList = await requestJson("/api/v1/datasources"); + const aliceList = await aliceClient.fetchJson("/api/v1/datasources"); assert.equal(aliceList.response.status, 200); assert.equal(aliceList.body.data.some((item) => item.id === "alice-front-db"), true); - const aliceSessions = await requestJson("/api/v1/sessions?limit=20"); + const aliceSessions = await aliceClient.fetchJson("/api/v1/sessions?limit=20"); assert.equal(aliceSessions.response.status, 200); assert.equal( aliceSessions.body.data.sessions.some((item) => item.title === "Alice isolated session"), true, ); - const bob = await registerVerifyLogin(bobEmail, "Bob Front"); - const bobDs = await createDatasource("bob-front-db", "Bob Front DB", bobDb); + const bobClient = createAuthenticatedTestClient({ baseUrl }); + const bob = await bobClient.registerAndLogin({ + email: `bob-front-${stamp}@example.com`, + displayName: "Bob Front", + }); + const bobDs = await createDatasource(bobClient, "bob-front-db", "Bob Front DB", bobDb); assert.equal(bobDs.response.status, 201, JSON.stringify(bobDs.body)); - const bobList = await requestJson("/api/v1/datasources"); + const bobList = await bobClient.fetchJson("/api/v1/datasources"); assert.equal(bobList.response.status, 200); assert.equal(bobList.body.data.some((item) => item.id === "bob-front-db"), true); assert.equal(bobList.body.data.some((item) => item.id === "alice-front-db"), false); - const bobSessions = await requestJson("/api/v1/sessions?limit=20"); + const bobSessions = await bobClient.fetchJson("/api/v1/sessions?limit=20"); assert.equal(bobSessions.response.status, 200); assert.equal( bobSessions.body.data.sessions.some((item) => item.title === "Alice isolated session"), false, ); - const missingCsrf = await requestJson("/api/v1/datasources/bob-front-db", { + // Intentionally omit X-CSRF-Token while keeping the session cookie. + const missingCsrf = await fetch(`${baseUrl}/api/v1/datasources/bob-front-db`, { method: "DELETE", + headers: { + Cookie: `df_session=${encodeURIComponent(bob.cookies.df_session)}; df_csrf=${encodeURIComponent(bob.cookies.df_csrf)}`, + }, }); - assert.equal(missingCsrf.response.status, 403); - assert.equal(missingCsrf.body.error.code, "FORBIDDEN"); + const missingCsrfBody = await missingCsrf.json(); + assert.equal(missingCsrf.status, 403); + assert.equal(missingCsrfBody.error.code, "CSRF_INVALID"); - const me = await requestJson("/api/v1/me"); - assert.equal(me.response.status, 200); - assert.equal(me.body.data.user.email, bobEmail); - assert.notEqual(me.body.data.user.id, alice.user.id); + const me = await bobClient.verifyCurrentUser(); + assert.equal(me.user.email, bob.email); + assert.notEqual(me.user.id, alice.userId); console.log( - `Password frontend isolation smoke OK via ${baseUrl}: alice=${alice.user.id.slice(0, 8)} bob=${bob.user.id.slice(0, 8)}`, + `Password frontend isolation smoke OK via ${baseUrl}: alice=${alice.userId.slice(0, 8)} bob=${bob.userId.slice(0, 8)}`, ); } catch (error) { console.error(error); diff --git a/scripts/smoke-server-datasources-e2e.mjs b/scripts/smoke-server-datasources-e2e.mjs index 592dd4d5..32fe3421 100644 --- a/scripts/smoke-server-datasources-e2e.mjs +++ b/scripts/smoke-server-datasources-e2e.mjs @@ -7,8 +7,14 @@ import { createServer as createApiServer } from "../apps/api/dist/server.js"; import { createTaskStateRuntime } from "../packages/agent-runtime/dist/index.js"; import { LocalDataGateway } from "../packages/data-gateway/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const root = mkdtempSync(join(tmpdir(), "open-data-foundry-server-datasources-e2e-")); +process.env.DATAFOUNDRY_AUTH_MODE = "password"; +process.env.AUTH_SESSION_SECRET = "server-datasources-e2e-session-secret-32b!"; +process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1:3000"; +process.env.AUTH_EMAIL_DELIVERY = "test"; +process.env.AUTH_REGISTRATION_MODE = "open"; process.env.STORAGE_ROOT_DIR = root; process.env.MASTRA_STORAGE_PATH = join(root, "mastra.sqlite"); process.env.EMBEDDING_API_KEY = ""; @@ -45,6 +51,9 @@ 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 client = createAuthenticatedTestClient({ baseUrl }); +const identity = await client.registerAndLogin({ displayName: "Server Datasources E2E" }); +const { userId, workspaceId } = identity; try { for (const target of targets) { @@ -84,11 +93,12 @@ async function verifyTarget(target) { assert.equal(schemaResponse.response.status, 200, JSON.stringify(schemaResponse.body)); assert(Array.isArray(schemaResponse.body.data.tables), `${target.type} schema tables missing`); - const schema = await dataGateway.inspectSchema({ user_id: "dev-user", datasource_id: datasourceId }); + const schema = await dataGateway.inspectSchema({ user_id: userId, workspace_id: workspaceId, datasource_id: datasourceId }); assert(Array.isArray(schema.tables), `${target.type} inspectSchema failed`); const result = await dataGateway.runSqlReadonly({ - user_id: "dev-user", + user_id: userId, + workspace_id: workspaceId, datasource_id: datasourceId, sql: target.sql, limit: 10 @@ -123,7 +133,7 @@ function serverTarget(type, prefix, overrides = {}) { } async function requestJson(path, init = {}) { - const response = await fetch(`${baseUrl}${path}`, init); + const response = await client.fetch(path, init); const body = await response.json(); return { body, response }; } diff --git a/scripts/test-builtin-dtc-growth-datasource.mjs b/scripts/test-builtin-dtc-growth-datasource.mjs index aae7a342..456b7b9b 100644 --- a/scripts/test-builtin-dtc-growth-datasource.mjs +++ b/scripts/test-builtin-dtc-growth-datasource.mjs @@ -14,6 +14,7 @@ import { import { createServer as createApiServer } from "../apps/api/dist/server.js"; import { LocalDataGateway } from "../packages/data-gateway/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoFixture = join(repoRoot, "storage/fixtures/dtc-growth-demo.sqlite"); @@ -130,6 +131,11 @@ test("ensureBuiltinDtcGrowthDatasource copies fixture and registers datasource i test("createServer auto-provisions DTC Growth Review and test-connect works", async () => { assert.ok(existsSync(repoFixture), `fixture missing: ${repoFixture}`); const root = mkdtempSync(join(tmpdir(), "dtc-growth-server-")); + process.env.DATAFOUNDRY_AUTH_MODE = "password"; + process.env.AUTH_SESSION_SECRET = "dtc-growth-server-session-secret-32b!"; + process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1:3000"; + process.env.AUTH_EMAIL_DELIVERY = "test"; + process.env.AUTH_REGISTRATION_MODE = "open"; process.env.STORAGE_ROOT_DIR = root; process.env.WORKSPACE_ROOT = join(root, "workspaces"); process.env.MASTRA_STORAGE_PATH = join(root, "mastra.sqlite"); @@ -140,15 +146,18 @@ test("createServer auto-provisions DTC Growth Review and test-connect works", as database_path: join(root, "metadata.sqlite"), secret_master_key: "dtc-growth-server-test-key" }); - const dataGateway = new LocalDataGateway(metadataStore, { workspaceId: "default" }); + const dataGateway = new LocalDataGateway(metadataStore); const server = await createApiServer({ metadataStore }); await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); const address = server.address(); assert(address && typeof address === "object"); const baseUrl = `http://127.0.0.1:${address.port}`; + const client = createAuthenticatedTestClient({ baseUrl }); + const identity = await client.registerAndLogin({ displayName: "Builtin DTC Growth" }); + const { userId, workspaceId } = identity; try { - const listed = await fetch(`${baseUrl}/api/v1/datasources`); + const listed = await client.fetch("/api/v1/datasources"); assert.equal(listed.status, 200); const listedBody = await listed.json(); const items = listedBody.data ?? listedBody; @@ -159,7 +168,7 @@ test("createServer auto-provisions DTC Growth Review and test-connect works", as assert.equal(dtc.builtin, true); assert.ok(typeof dtc.config?.path === "string" && existsSync(dtc.config.path)); - const testConnect = await fetch(`${baseUrl}/api/v1/datasources/${DTC_GROWTH_DATASOURCE_ID}/test`, { + const testConnect = await client.fetch(`/api/v1/datasources/${DTC_GROWTH_DATASOURCE_ID}/test`, { method: "POST" }); const testBody = await testConnect.json(); @@ -168,8 +177,8 @@ test("createServer auto-provisions DTC Growth Review and test-connect works", as // Gateway query against provisioned path. const schema = await dataGateway.inspectSchema({ - user_id: "dev-user", - workspace_id: "default", + user_id: userId, + workspace_id: workspaceId, datasource_id: DTC_GROWTH_DATASOURCE_ID }); const tableNames = schema.tables.map((table) => table.name).sort(); @@ -177,7 +186,7 @@ test("createServer auto-provisions DTC Growth Review and test-connect works", as assert.ok(tableNames.includes("ad_spend")); // Second list remains single datasource (idempotent via memo + ensure). - const listedAgain = await fetch(`${baseUrl}/api/v1/datasources`); + const listedAgain = await client.fetch("/api/v1/datasources"); const againBody = await listedAgain.json(); const againItems = againBody.data ?? againBody; const dtcCount = (Array.isArray(againItems) ? againItems : []).filter( diff --git a/scripts/verify-token-usage-display.mjs b/scripts/verify-token-usage-display.mjs index a2a5dac0..40b2926b 100644 --- a/scripts/verify-token-usage-display.mjs +++ b/scripts/verify-token-usage-display.mjs @@ -2,6 +2,7 @@ * Live API + frontend state verification for token_usage display. * Replays AG-UI events through live-run-state and prints overview/detail token stats. */ +import { createAuthenticatedTestClient } from "./lib/authenticated-test-client.mjs"; import { createInitialLiveRun, deriveRunUsage, @@ -10,10 +11,12 @@ import { } from "../apps/web/src/app/data-tasks/live-run-state.ts"; const API_BASE = process.env.API_BASE_URL ?? "http://127.0.0.1:8787"; +const client = createAuthenticatedTestClient({ baseUrl: API_BASE }); +await client.registerAndLogin({ displayName: "Token Usage Verify" }); const threadId = `token-verify-${Date.now()}`; const runId = `token-verify-run-${Date.now()}`; -const response = await fetch(`${API_BASE}/api/copilotkit`, { +const response = await client.fetch("/api/copilotkit", { method: "POST", headers: { Accept: "text/event-stream", From 5442faba83291bfed939f36fcf652b81d896103d Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Sun, 26 Jul 2026 16:10:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(auth):=20=E7=95=B8=E5=BD=A2=20Cookie=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=AF=BC=E8=87=B4=20password=20=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseCookies 在 percent-encoding 非法时抛 URIError,会使 /api/v1/* 落入 500。改为跳过坏 cookie 项;部署 HTTP 脚本分类仅在文件存在时生效, 便于本分支直接基于 main 合入而不依赖部署目录。 --- apps/api/src/auth/cookies.ts | 25 +++++++++++++++----- scripts/auth-foundation.test.mjs | 39 ++++++++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/apps/api/src/auth/cookies.ts b/apps/api/src/auth/cookies.ts index 3c2b38e1..63e06d52 100644 --- a/apps/api/src/auth/cookies.ts +++ b/apps/api/src/auth/cookies.ts @@ -13,12 +13,25 @@ export function parseCookies(request: IncomingMessage): Record { if (!header) { return {}; } - return Object.fromEntries( - header.split(";").map((part) => { - const [name, ...rest] = part.trim().split("="); - return [name, decodeURIComponent(rest.join("="))]; - }).filter(([name]) => Boolean(name)) - ); + const cookies: Record = {}; + for (const part of header.split(";")) { + const trimmed = part.trim(); + if (!trimmed) { + continue; + } + const eq = trimmed.indexOf("="); + const name = (eq === -1 ? trimmed : trimmed.slice(0, eq)).trim(); + if (!name) { + continue; + } + const rawValue = eq === -1 ? "" : trimmed.slice(eq + 1); + try { + cookies[name] = decodeURIComponent(rawValue); + } catch { + // Ignore malformed percent-encoding; treat the cookie as absent. + } + } + return cookies; } export function appendAuthCookies(response: ServerResponse, input: { diff --git a/scripts/auth-foundation.test.mjs b/scripts/auth-foundation.test.mjs index ac9e8ac8..6c6eed6a 100644 --- a/scripts/auth-foundation.test.mjs +++ b/scripts/auth-foundation.test.mjs @@ -16,7 +16,8 @@ import { } from "../apps/api/dist/auth/config.js"; import { appendAuthCookies, - appendClearAuthCookies + appendClearAuthCookies, + parseCookies } from "../apps/api/dist/auth/cookies.js"; const SCRIPTS_ROOT = dirname(fileURLToPath(import.meta.url)); @@ -38,10 +39,17 @@ const FORMAL_HTTP_AUTH_TARGETS = [ "verify-token-usage-display.mjs" ]; +// Deploy HTTP scripts are classified when present (Plan B). Do not require them on main-only trees. const PUBLIC_HTTP_TARGETS = [ "deploy/health.mjs", "deploy/smoke-native-deploy.mjs" -]; +].filter((relativePath) => { + try { + return statSync(join(SCRIPTS_ROOT, relativePath)).isFile(); + } catch { + return false; + } +}); const DIRECT_METADATA_FIXTURE_TARGETS = []; @@ -220,6 +228,33 @@ async function captureSetCookie(write) { } } +test("parseCookies ignores malformed percent-encoding instead of throwing", () => { + const request = { + headers: { + cookie: "df_session=%E0%A4%A; df_csrf=ok-token; broken=%ZZ; plain=hello%20world" + } + }; + assert.doesNotThrow(() => parseCookies(request)); + const cookies = parseCookies(request); + assert.equal(cookies.df_csrf, "ok-token"); + assert.equal(cookies.plain, "hello world"); + assert.equal(cookies.df_session, undefined); + assert.equal(cookies.broken, undefined); +}); + +test("malformed Cookie header yields 401 rather than 500 in password mode", async () => { + await withPasswordApi({}, async ({ baseUrl }) => { + const response = await fetch(`${baseUrl}/api/v1/me`, { + headers: { + Cookie: "df_session=%E0%A4%A; df_csrf=not-a-session" + } + }); + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.error.code, "UNAUTHORIZED"); + }); +}); + test("appendAuthCookies and clear use the same secure flag", async () => { const secureCookies = await captureSetCookie((res) => { appendAuthCookies(res, {