diff --git a/.env.example b/.env.example index 7b5929b2..867b55b6 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ API_HOST=127.0.0.1 API_PORT=8787 -WEB_HOST=0.0.0.0 +WEB_HOST=127.0.0.1 WEB_PORT=3000 # LLM_* values are optional server-side defaults. Models can also be created in the Web UI after deploy. @@ -59,9 +59,6 @@ SECRET_MASTER_KEY=replace-with-a-long-random-local-key # --------------------------------------------------------------------------- # Formal deploy (default): password + build/start. Do NOT use npm run dev here. # -# Keep in sync with apps/web/.env.local → NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE -# (NEXT_PUBLIC_* is baked in at `next build`). Leave public API URLs empty and -# set API_PROXY_TARGET so the browser uses the same-origin Next BFF. # # Two formal environments share the same start commands; differ mainly here: # @@ -74,9 +71,7 @@ SECRET_MASTER_KEY=replace-with-a-long-random-local-key # AUTH_EMAIL_DELIVERY=smtp # fill AUTH_SMTP_* below # + reverse proxy: deploy/nginx.datafoundry.conf.example # -# Contributor hot-reload only (not formal): DATAFOUNDRY_AUTH_MODE=dev # --------------------------------------------------------------------------- -DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 # Required in password mode (open|closed). open = self-register; closed = REGISTRATION_CLOSED. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 343202c4..bbd42914 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,6 @@ jobs: 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 @@ -79,6 +78,9 @@ jobs: - name: Run formal auth foundation tests run: npm run test:auth-foundation + - name: Enforce password-only cutover + run: npm run test:password-only + - name: Generate built-in demo fixture env: SKIP_DTC_GROWTH_REGISTER: "1" diff --git a/README.md b/README.md index 3da660a1..75180640 100644 --- a/README.md +++ b/README.md @@ -122,16 +122,15 @@ LLM_MODEL=qwen-plus # or deepseek-chat, gpt-4o, ... LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_API_KEY=replace-with-your-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 # real production: https://your.domain +AUTH_REGISTRATION_MODE=open AUTH_EMAIL_DELIVERY=test # real production: smtp + AUTH_SMTP_* ``` `apps/web/.env.local`: ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= API_PROXY_TARGET=http://127.0.0.1:8787 diff --git a/README_zh.md b/README_zh.md index eeabaed3..dc52939d 100644 --- a/README_zh.md +++ b/README_zh.md @@ -122,16 +122,15 @@ LLM_MODEL=qwen-plus # 或 deepseek-chat、gpt-4o…… LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 LLM_API_KEY=replace-with-your-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 # 真实生产改为 https://你的域名 +AUTH_REGISTRATION_MODE=open AUTH_EMAIL_DELIVERY=test # 真实生产改为 smtp,并填 AUTH_SMTP_* ``` `apps/web/.env.local`: ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= API_PROXY_TARGET=http://127.0.0.1:8787 diff --git a/apps/api/src/auth/config.ts b/apps/api/src/auth/config.ts index ecdccf5e..bc7e9272 100644 --- a/apps/api/src/auth/config.ts +++ b/apps/api/src/auth/config.ts @@ -1,8 +1,6 @@ -export type AuthMode = "dev" | "password"; export type RegistrationMode = "open" | "closed"; export type PasswordAuthConfig = { - mode: AuthMode; publicBaseUrl: string; registrationMode: RegistrationMode; cookiePath: string; @@ -69,11 +67,19 @@ export function validateAuthPublicUrl(raw: string): { } export function loadPasswordAuthConfig(env: Record): PasswordAuthConfig { - const mode = parseAuthMode(env.DATAFOUNDRY_AUTH_MODE, env.NODE_ENV); + // Upgrade shim: previous .env.example / deploy defaults set DATAFOUNDRY_AUTH_MODE=password. + // Ignore empty/password leftovers; reject removed modes (especially dev). + if (env.DATAFOUNDRY_AUTH_MODE !== undefined) { + const legacyMode = String(env.DATAFOUNDRY_AUTH_MODE).trim(); + if (legacyMode !== "" && legacyMode !== "password") { + throw new Error( + "AUTH_CONFIG_INVALID:DATAFOUNDRY_AUTH_MODE is no longer supported; remove it from the environment (dev mode has been removed)." + ); + } + } const config: PasswordAuthConfig = { - mode, publicBaseUrl: env.AUTH_PUBLIC_BASE_URL ?? "", - registrationMode: parseRegistrationMode(env.AUTH_REGISTRATION_MODE, mode === "password"), + registrationMode: parseRegistrationMode(env.AUTH_REGISTRATION_MODE), cookiePath: "/", cookieSecure: false, sessionSecret: env.AUTH_SESSION_SECRET ?? "", @@ -91,35 +97,13 @@ 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. - } - } + validatePasswordAuthConfig(config); return config; } -function parseAuthMode(value: string | undefined, nodeEnv: string | undefined): AuthMode { - if (value === "password" || value === "dev") { - return value; - } - return nodeEnv === "production" ? "password" : "dev"; -} - -function parseRegistrationMode(value: string | undefined, required: boolean): RegistrationMode { +function parseRegistrationMode(value: string | undefined): RegistrationMode { if (value === undefined || value.trim() === "") { - if (required) { - throw new Error("AUTH_CONFIG_MISSING:AUTH_REGISTRATION_MODE is required in password mode."); - } - return "open"; + throw new Error("AUTH_CONFIG_MISSING:AUTH_REGISTRATION_MODE is required."); } if (value === "open" || value === "closed") { return value; diff --git a/apps/api/src/config-api.ts b/apps/api/src/config-api.ts index dc3beeeb..20401312 100644 --- a/apps/api/src/config-api.ts +++ b/apps/api/src/config-api.ts @@ -174,9 +174,6 @@ const routeConfigRequest = async ( if (root === "me") { return handleMeRequest(request, context); } - if (root === "dev") { - return handleDevIdentityRequest(request, segments.slice(1), context); - } if (root === "datasource-types" && request.method === "GET") { return ok(await context.dataGateway.supportTypes()); } @@ -247,48 +244,11 @@ const handleMeRequest = ( } const user = context.metadataStore.users.getById({ user_id: context.userId }); return ok({ - user: devIdentityUserDto(user), + user: authUserDto(user), workspace: defaultWorkspaceDto(context.workspaceId) }); }; -const handleDevIdentityRequest = async ( - request: IncomingMessage, - segments: string[], - context: Required -): Promise => { - if (!isDevIdentityApiEnabled()) { - return fail(404, "RESOURCE_NOT_FOUND", "Dev identity API is not enabled."); - } - const resource = segments[0]; - if (resource === "identities" && request.method === "GET") { - return ok({ - users: context.metadataStore.users.list().map(devIdentityUserDto), - currentUserId: context.userId, - workspace: defaultWorkspaceDto(context.workspaceId) - }); - } - if (resource === "users" && request.method === "POST") { - const body = await readJsonBody(request); - const id = sanitizeDevUserId(stringValue(body.id) ?? stringValue(body.userId) ?? ""); - const email = sanitizeOptionalEmail(stringValue(body.email), id); - const displayName = sanitizeDisplayName( - stringValue(body.displayName) ?? stringValue(body.display_name) ?? id - ); - const user = context.metadataStore.users.upsertDevUser({ - id, - email, - display_name: displayName, - dev_token: `dev-token-${id}` - }); - return ok({ user: devIdentityUserDto(user) }, 201); - } - return fail(404, "RESOURCE_NOT_FOUND", `Unknown dev identity resource: ${resource ?? ""}`); -}; - -const isDevIdentityApiEnabled = (): boolean => - process.env.NODE_ENV !== "production" || process.env.DATAFOUNDRY_ENABLE_DEV_IDENTITY_API === "true"; - const handleChatUpload = async ( request: IncomingMessage, context: Required @@ -3577,11 +3537,10 @@ const listConfig = (context: Required, kind: ConfigResourceKin kind }).map(configResourceDto); -const devIdentityUserDto = (user: UserRecord): Record => ({ +const authUserDto = (user: UserRecord): Record => ({ id: user.id, ...(user.email ? { email: user.email } : {}), - ...(user.display_name ? { displayName: user.display_name } : {}), - ...(user.dev_token ? { devToken: user.dev_token } : {}) + ...(user.display_name ? { displayName: user.display_name } : {}) }); const defaultWorkspaceDto = (workspaceId: string): Record => ({ @@ -3589,33 +3548,6 @@ const defaultWorkspaceDto = (workspaceId: string): Record => ({ name: workspaceId === DEFAULT_WORKSPACE_ID ? "Default workspace" : workspaceId }); -const sanitizeDevUserId = (value: string): string => { - const id = value.trim(); - if (!/^[a-zA-Z0-9._-]{1,128}$/u.test(id)) { - throw new Error("INVALID_DEV_USER_ID"); - } - return id; -}; - -const sanitizeDisplayName = (value: string): string => { - const normalized = value.replace(/\s+/g, " ").trim(); - if (!normalized) { - throw new Error("DEV_USER_DISPLAY_NAME_REQUIRED"); - } - return normalized.slice(0, 80); -}; - -const sanitizeOptionalEmail = (value: string | undefined, id: string): string => { - const normalized = value?.trim(); - if (!normalized) { - return `${id}@local.dev`; - } - if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/u.test(normalized)) { - throw new Error("INVALID_DEV_USER_EMAIL"); - } - return normalized.slice(0, 254); -}; - const dataSourceDto = (record: DataSourceRecord): Record => { const config = parseRecord(record.config_json); return { diff --git a/apps/api/src/protocol-state-store.test.ts b/apps/api/src/protocol-state-store.test.ts index 4bedc6e0..e7d3ab8e 100644 --- a/apps/api/src/protocol-state-store.test.ts +++ b/apps/api/src/protocol-state-store.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createMetadataStore } from "@datafoundry/metadata"; +import { createMetadataStore, createVerifiedTestIdentity } from "@datafoundry/metadata"; import { MetadataProtocolStateStore } from "./protocol-state-store.js"; describe("MetadataProtocolStateStore", () => { @@ -11,23 +11,24 @@ describe("MetadataProtocolStateStore", () => { const root = mkdtempSync(join(tmpdir(), "protocol-state-store-")); try { const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + const { userId } = createVerifiedTestIdentity(metadata); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Protocol" }); metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-1", session_id: "session-1", user_input: "test", status: "running" }); metadata.contextPackageSnapshots.create({ - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: "run-1", package_id: "context-1", revision: 0, payload: {} }); - const store = new MetadataProtocolStateStore(metadata, "dev-user"); + const store = new MetadataProtocolStateStore(metadata, userId); const startedEvent = { eventId: "run-1:segment:1:0:protocol.run.started", type: "protocol.run.started", @@ -51,7 +52,7 @@ describe("MetadataProtocolStateStore", () => { domain: {} }, [startedEvent]); - const restored = new MetadataProtocolStateStore(metadata, "dev-user") + const restored = new MetadataProtocolStateStore(metadata, userId) .get("run-1", "run-1:segment:1"); expect(restored).toMatchObject({ protocolId: "general-task", revision: 0, phase: "work" }); expect(store.pendingEvents("run-1")).toEqual([startedEvent]); @@ -67,23 +68,24 @@ describe("MetadataProtocolStateStore", () => { const root = mkdtempSync(join(tmpdir(), "protocol-state-handoff-")); try { const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + const { userId } = createVerifiedTestIdentity(metadata); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Protocol" }); metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-1", session_id: "session-1", user_input: "test", status: "running" }); metadata.contextPackageSnapshots.create({ - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: "run-1", package_id: "context-1", revision: 0, payload: {} }); - const store = new MetadataProtocolStateStore(metadata, "dev-user"); + const store = new MetadataProtocolStateStore(metadata, userId); const current = store.create(createState("general-task", "run-1:segment:1", 0, "active")); store.transitionSegment({ diff --git a/apps/api/src/run-checkpoint-projector.test.ts b/apps/api/src/run-checkpoint-projector.test.ts index eecd7af6..9c898a80 100644 --- a/apps/api/src/run-checkpoint-projector.test.ts +++ b/apps/api/src/run-checkpoint-projector.test.ts @@ -1,5 +1,5 @@ import { createCustomEvent } from "@datafoundry/agent-runtime"; -import { createMetadataStore, RunEventWriter } from "@datafoundry/metadata"; +import { createMetadataStore, createVerifiedTestIdentity, RunEventWriter } from "@datafoundry/metadata"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -12,16 +12,17 @@ describe("RunCheckpointProjector protocol events", () => { const root = mkdtempSync(join(tmpdir(), "protocol-checkpoint-")); try { const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + const { userId } = createVerifiedTestIdentity(metadata); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Protocol" }); metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-1", session_id: "session-1", user_input: "test", status: "running" }); metadata.contextPackageSnapshots.create({ - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: "run-1", package_id: "context-1", @@ -29,7 +30,7 @@ describe("RunCheckpointProjector protocol events", () => { payload: {} }); const envelope = new RunEventWriter(metadata.runEvents).write({ - user_id: "dev-user", + user_id: userId, run_id: "run-1", session_id: "session-1", event: createCustomEvent("protocol.phase.entered", { @@ -38,9 +39,9 @@ describe("RunCheckpointProjector protocol events", () => { }) }); - new RunCheckpointProjector(metadata, "dev-user").observe(envelope); + new RunCheckpointProjector(metadata, userId).observe(envelope); - expect(metadata.checkpoints.latestByRun({ user_id: "dev-user", run_id: "run-1" })).toMatchObject({ + expect(metadata.checkpoints.latestByRun({ user_id: userId, run_id: "run-1" })).toMatchObject({ kind: "protocol-phase", label: "Protocol phase: query_planning", context_package_revision: 2 diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 6aa07187..9ab730b6 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -41,7 +41,7 @@ import { handleConfigApiRequest } from "./config-api.js"; import { createAsyncMemoByKey, createStartupTimer } from "./async-memo.js"; import { ensureBuiltinDtcGrowthDatasource } from "./builtin-dtc-growth-datasource.js"; import { reclaimOrphanedQueuedAndRunningRuns } from "./stale-active-runs.js"; -import { loadPasswordAuthConfig, type PasswordAuthConfig } from "./auth/config.js"; +import { loadPasswordAuthConfig } from "./auth/config.js"; import { AuthService, type AuthIdentity } from "./auth/service.js"; import { serverDefaultConnectionStatus, isServerLlmEnvConfigured } from "./model-profile-connection-status.js"; import { @@ -76,12 +76,6 @@ import { startSessionTitleTask } from "./session-title.js"; import { TaskPlanProjector } from "./task-plan-projector.js"; import { ToolCallResultBridge } from "./tool-call-result-bridge.js"; -const DEV_USER: MeResponse = { - id: "dev-user", - email: "dev@example.com", - display_name: "Dev User" -}; - const COPILOTKIT_PATH = "/api/copilotkit"; const DEFAULT_WORKSPACE_ID = "default"; const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); @@ -176,13 +170,7 @@ export const createServer = async (options: CreateServerOptions = {}): Promise taskStateRuntimePromise), - timer.measure("builtin_resources", () => - ensureBuiltinConfigResourcesOnce(fileAssetService, metadataStore, DEV_USER.id, DEFAULT_WORKSPACE_ID), - ), - ]); + const taskStateRuntime = await timer.measure("mastra_runtime", () => taskStateRuntimePromise); // After restart, cancel-registry is empty — reclaim queued/running rows left by dead workers. const reclaimedActiveRuns = await timer.measure("stale_active_run_reclaim", () => @@ -270,7 +251,7 @@ export const createServer = async (options: CreateServerOptions = {}): Promise { response.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization, Idempotency-Key, If-Match, X-CSRF-Token, X-Dev-Token, X-Workspace-Id", + "Access-Control-Allow-Headers": "Content-Type, Authorization, Idempotency-Key, If-Match, X-CSRF-Token", "Access-Control-Max-Age": "86400" }); response.end(); @@ -1083,47 +1064,13 @@ type RequestAuthContext = { const resolveRequestAuth = ( request: IncomingMessage, - metadataStore: MetadataStore, - authConfig: PasswordAuthConfig, authService: AuthService ): RequestAuthContext => { - if (isPasswordAuth(authConfig)) { - const identity = resolvePasswordSessionIdentity(authService, request); - return { - identity, - user: userRecordToMeResponse(identity.user), - workspaceId: identity.workspace.id - }; - } - - const token = extractAuthToken(request); - const workspaceId = sanitizeWorkspaceId(headerString(request.headers["x-workspace-id"])); - const devUser = metadataStore.users.getById({ user_id: DEV_USER.id }); - const devIdentity = { - user: devUser, - workspace: { id: workspaceId, name: workspaceId, kind: "personal" as const, owner_user_id: DEV_USER.id, created_at: "", updated_at: "" } - }; - if (!token) { - return { identity: devIdentity, user: DEV_USER, workspaceId }; - } - const user = metadataStore.users.getByDevToken({ dev_token: token }); - if (!user) { - throw new Error("UNAUTHORIZED:Invalid local auth token."); - } + const identity = resolvePasswordSessionIdentity(authService, request); return { - identity: { - user, - workspace: { - id: workspaceId, - name: workspaceId, - kind: "personal", - owner_user_id: user.id, - created_at: "", - updated_at: "" - } - }, - user: userRecordToMeResponse(user), - workspaceId + identity, + user: userRecordToMeResponse(identity.user), + workspaceId: identity.workspace.id }; }; @@ -1147,24 +1094,6 @@ const ensureConversationWorkingMemoryThread = async (input: { }); }; -const isPasswordAuth = (config: PasswordAuthConfig): boolean => config.mode === "password"; - -const extractAuthToken = (request: IncomingMessage): string | undefined => { - const authorization = headerString(request.headers.authorization); - if (authorization?.startsWith("Bearer ")) { - return authorization.slice("Bearer ".length).trim() || undefined; - } - return headerString(request.headers["x-dev-token"]); -}; - -const sanitizeWorkspaceId = (value: string | undefined): string => { - const candidate = value?.trim() || DEFAULT_WORKSPACE_ID; - if (!/^[a-zA-Z0-9._-]{1,128}$/u.test(candidate)) { - throw new Error("UNAUTHORIZED:Invalid workspace id."); - } - return candidate; -}; - const headerString = (value: string | string[] | undefined): string | undefined => Array.isArray(value) ? value[0] : value; @@ -1174,15 +1103,6 @@ const userRecordToMeResponse = (user: UserRecord): MeResponse => ({ ...(user.display_name ? { display_name: user.display_name } : {}) }); -const ensureDevUser = (metadataStore: MetadataStore): void => { - metadataStore.users.upsertDevUser({ - id: DEV_USER.id, - email: DEV_USER.email ?? "dev@example.com", - display_name: DEV_USER.display_name ?? "Dev User", - dev_token: "dev-token" - }); -}; - const sendJson = (response: ServerResponse, statusCode: number, body: unknown): void => { response.writeHead(statusCode, { "Access-Control-Allow-Origin": "*", diff --git a/apps/tui/src/no-bare-fetch.guard.test.ts b/apps/tui/src/no-bare-fetch.guard.test.ts index 3255293d..4101dd7b 100644 --- a/apps/tui/src/no-bare-fetch.guard.test.ts +++ b/apps/tui/src/no-bare-fetch.guard.test.ts @@ -65,8 +65,10 @@ describe("no bare fetch on protected TUI API paths", () => { assert.deepEqual(offenders, []); }); - it("forbids offline demo symbols in apps, README, and docs", () => { - const banned = /DemoCopilotKitClient|seedDemoState|--demo/; + it("forbids offline demo symbols in production sources", () => { + const banned = new RegExp( + ["DemoCopilotKitClient", "seedDemoState", ["-", "-", "demo"].join("")].join("|"), + ); const offenders: string[] = []; const roots = [ join(repoRoot, "apps"), diff --git a/apps/web/.env.example b/apps/web/.env.example index 14958d99..2209296f 100755 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -4,22 +4,8 @@ # It uses same-origin BFF variables (empty NEXT_PUBLIC_* URLs) and sets # API_PROXY_TARGET from the selected API host/port. # -# Keep in sync with root `.env` → DATAFOUNDRY_AUTH_MODE. -# 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, -# 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). NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= API_PROXY_TARGET=http://127.0.0.1:8787 -# -# --- Contributor hot-reload only (not formal; do not mix with start:*) --- -# NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=dev -# NEXT_PUBLIC_AGENT_RUNTIME_URL=http://127.0.0.1:8787/api/copilotkit -# NEXT_PUBLIC_CONFIG_API_URL=http://127.0.0.1:8787 diff --git a/apps/web/src/app/data-tasks/__tests__/config-api-adapter.test.ts b/apps/web/src/app/data-tasks/__tests__/config-api-adapter.test.ts index 3e5f7a24..9abeef86 100644 --- a/apps/web/src/app/data-tasks/__tests__/config-api-adapter.test.ts +++ b/apps/web/src/app/data-tasks/__tests__/config-api-adapter.test.ts @@ -20,7 +20,8 @@ afterEach(() => { }); describe("config api adapter", () => { - it("adds current dev identity headers to REST requests", async () => { + it("does not add development auth headers to REST requests", async () => { + process.env.NEXT_PUBLIC_CONFIG_API_URL = ""; const fetchMock = vi.fn(async () => new Response(JSON.stringify({ success: true, data: { "chat.fileUpload": true }, @@ -30,25 +31,25 @@ describe("config api adapter", () => { userId: "tenant-user", displayName: "Tenant User", email: "tenant@example.com", - devToken: "tenant-token", }); await configApi.getCapabilities(); expect(fetchMock).toHaveBeenCalledWith( - "http://127.0.0.1:8787/api/v1/capabilities", + "/api/v1/capabilities", expect.objectContaining({ + credentials: "same-origin", headers: expect.objectContaining({ Accept: "application/json", - Authorization: "Bearer tenant-token", - "X-Workspace-Id": "default", }), }), ); + expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toMatchObject({ + Authorization: expect.anything(), + }); }); - it("uses cookie credentials and csrf headers in password auth mode", async () => { - process.env.NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE = "password"; + it("uses cookie credentials and csrf headers", async () => { process.env.NEXT_PUBLIC_CONFIG_API_URL = ""; vi.stubGlobal("document", { cookie: "df_csrf=csrf-token" }); const fetchMock = vi.fn(async () => new Response(JSON.stringify({ @@ -60,7 +61,6 @@ describe("config api adapter", () => { userId: "tenant-user", displayName: "Tenant User", email: "tenant@example.com", - devToken: "tenant-token", }); await configApi.deleteDatasource("db-1"); @@ -75,9 +75,6 @@ describe("config api adapter", () => { }), }), ); - expect(fetchMock.mock.calls[0]?.[1]?.headers).not.toMatchObject({ - Authorization: "Bearer tenant-token", - }); }); it("maps datasource dto into workspace item settings", () => { diff --git a/apps/web/src/app/data-tasks/__tests__/identity-menu.test.ts b/apps/web/src/app/data-tasks/__tests__/identity-menu.test.ts index 2cde5de0..c049043a 100644 --- a/apps/web/src/app/data-tasks/__tests__/identity-menu.test.ts +++ b/apps/web/src/app/data-tasks/__tests__/identity-menu.test.ts @@ -41,7 +41,7 @@ describe("data task identity menu", () => { ); }); - it("does not expose placeholder account menu items", () => { + it("does not expose development identity paths", () => { const file = source(); for (const label of [ @@ -55,20 +55,15 @@ describe("data task identity menu", () => { "Switch account", "Add account", "Local dev users authenticate with dev tokens", + "Continue as Dev User", + "DEV_SIGNED_OUT_STORAGE_KEY", + "DevIdentityProvider", + "getDevIdentities", ]) { expect(file).not.toContain(label); } }); - it("gives local dev sign out a visible signed-out state", () => { - const file = source(); - - expect(file).toContain("DEV_SIGNED_OUT_STORAGE_KEY"); - expect(file).toContain("removeStoredIdentity"); - expect(file).toContain("Signed out"); - expect(file).toContain("Continue as Dev User"); - }); - it("uses a divider-only trigger and a flush animated popover", () => { const file = source(); @@ -80,8 +75,6 @@ describe("data task identity menu", () => { }); it("exposes production password auth screens and account actions", () => { - // Auth screens moved to a standalone flow component behind /login routes; - // the identity provider only delegates to it. const authFlow = readFileSync( join(process.cwd(), "src/components/auth/auth-flow.tsx"), "utf8", @@ -98,5 +91,6 @@ describe("data task identity menu", () => { const file = source(); expect(file).toContain("PasswordAuthShell"); + expect(file).toContain('authMode: "password"'); }); }); diff --git a/apps/web/src/app/data-tasks/data-task-identity.tsx b/apps/web/src/app/data-tasks/data-task-identity.tsx index aadaddf1..70a865ad 100644 --- a/apps/web/src/app/data-tasks/data-task-identity.tsx +++ b/apps/web/src/app/data-tasks/data-task-identity.tsx @@ -5,7 +5,6 @@ import { useCallback, useContext, useEffect, - useLayoutEffect, useMemo, useState, type ReactNode, @@ -14,107 +13,26 @@ import { useRouter } from "next/navigation"; import { clearConfigApiIdentity, configApi, - isPasswordAuthMode, - setConfigApiIdentity, type ConfigApiIdentity, } from "../../lib/config-api/client"; -import type { DevIdentityUser } from "../../lib/config-api"; -import { AUTH_BUTTON_CLASS, PasswordAuthShell } from "../../components/auth/auth-flow"; - -const IDENTITY_STORAGE_KEY = "data-tasks:identity:v1"; -const DEV_SIGNED_OUT_STORAGE_KEY = "data-tasks:identity:signed-out:v1"; - -const DEFAULT_IDENTITY: ConfigApiIdentity = { - userId: "dev-user", - displayName: "Dev User", - email: "dev@example.com", - devToken: "dev-token", -}; +import { PasswordAuthShell } from "../../components/auth/auth-flow"; +import { LanguageToggle } from "../../i18n/LanguageToggle"; +import { useT } from "../../i18n/locale-context"; type DataTaskIdentityContextValue = { - authMode: "dev" | "password"; + authMode: "password"; authHeaders: Record; changePassword: (input: { currentPassword: string; newPassword: string }) => Promise; - createUser: (input: { displayName: string; email?: string; id?: string }) => Promise; currentUser: ConfigApiIdentity; error: string | null; loading: boolean; scopeKey: string; - selectUser: (userId: string) => void; signOut: () => void; signOutAll: () => void; - users: ConfigApiIdentity[]; }; const DataTaskIdentityContext = createContext(null); -function storageIdentity(): ConfigApiIdentity { - if (typeof window === "undefined") return DEFAULT_IDENTITY; - try { - const raw = window.localStorage.getItem(IDENTITY_STORAGE_KEY); - if (!raw) return DEFAULT_IDENTITY; - const parsed = JSON.parse(raw) as Partial; - if (!parsed.userId || !parsed.devToken) return DEFAULT_IDENTITY; - return { - userId: parsed.userId, - displayName: parsed.displayName || parsed.userId, - ...(parsed.email ? { email: parsed.email } : {}), - devToken: parsed.devToken, - }; - } catch { - return DEFAULT_IDENTITY; - } -} - -function persistIdentity(identity: ConfigApiIdentity): void { - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(identity)); - } catch { - // Identity stays in memory when localStorage is unavailable. - } -} - -function removeStoredIdentity(): void { - if (typeof window === "undefined") return; - try { - window.localStorage.removeItem(IDENTITY_STORAGE_KEY); - } catch { - // Identity stays in memory when localStorage is unavailable. - } -} - -function storageDevSignedOut(): boolean { - if (typeof window === "undefined") return false; - try { - return window.localStorage.getItem(DEV_SIGNED_OUT_STORAGE_KEY) === "true"; - } catch { - return false; - } -} - -function persistDevSignedOut(signedOut: boolean): void { - if (typeof window === "undefined") return; - try { - if (signedOut) { - window.localStorage.setItem(DEV_SIGNED_OUT_STORAGE_KEY, "true"); - } else { - window.localStorage.removeItem(DEV_SIGNED_OUT_STORAGE_KEY); - } - } catch { - // Signed-out state stays in memory when localStorage is unavailable. - } -} - -function dtoToIdentity(user: DevIdentityUser): ConfigApiIdentity { - return { - userId: user.id, - displayName: user.displayName || user.id, - ...(user.email ? { email: user.email } : {}), - devToken: user.devToken ?? "", - }; -} - function identityInitials(identity: ConfigApiIdentity): string { const source = identity.displayName || identity.userId; const parts = source.split(/\s+/u).filter(Boolean); @@ -124,172 +42,16 @@ function identityInitials(identity: ConfigApiIdentity): string { return source.slice(0, 2).toUpperCase(); } -function slugFromName(value: string): string { - const slug = value - .trim() - .toLowerCase() - .replace(/[^a-z0-9._-]+/gu, "-") - .replace(/^-+|-+$/gu, ""); - return slug || `user-${Date.now()}`; +function dtoToIdentity(user: { id: string; displayName?: string; email?: string }): ConfigApiIdentity { + return { + userId: user.id, + displayName: user.displayName || user.id, + ...(user.email ? { email: user.email } : {}), + }; } export function DataTaskIdentityProvider({ children }: { children: ReactNode }) { - if (isPasswordAuthMode()) { - return {children}; - } - return {children}; -} - -function DevIdentityProvider({ children }: { children: ReactNode }) { - const [currentUser, setCurrentUser] = useState(() => storageIdentity()); - const [users, setUsers] = useState(() => [storageIdentity()]); - const [signedOut, setSignedOut] = useState(() => storageDevSignedOut()); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useLayoutEffect(() => { - if (signedOut) { - clearConfigApiIdentity(); - removeStoredIdentity(); - persistDevSignedOut(true); - return; - } - setConfigApiIdentity(currentUser); - persistIdentity(currentUser); - persistDevSignedOut(false); - }, [currentUser, signedOut]); - - useEffect(() => { - if (signedOut) return; - let cancelled = false; - setLoading(true); - setError(null); - void configApi - .getDevIdentities() - .then((response) => { - if (cancelled) return; - const nextUsers = response.users.map(dtoToIdentity); - setUsers(nextUsers.length > 0 ? nextUsers : [currentUser]); - const current = nextUsers.find((user) => user.userId === currentUser.userId); - if (current) { - setCurrentUser(current); - } - }) - .catch((err) => { - if (!cancelled) { - setError(err instanceof Error ? err.message : "Failed to load users"); - setUsers((current) => (current.length > 0 ? current : [currentUser])); - } - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); - return () => { - cancelled = true; - }; - }, [currentUser.userId, signedOut]); - - const selectUser = useCallback( - (userId: string) => { - const next = users.find((user) => user.userId === userId); - if (next) setCurrentUser(next); - }, - [users], - ); - - const signOut = useCallback(() => { - clearConfigApiIdentity(); - removeStoredIdentity(); - persistDevSignedOut(true); - setSignedOut(true); - }, []); - - const signOutAll = useCallback(() => { - signOut(); - }, [signOut]); - - const continueAsDevUser = useCallback(() => { - setCurrentUser(DEFAULT_IDENTITY); - setUsers([DEFAULT_IDENTITY]); - setSignedOut(false); - }, []); - - const changePassword = useCallback(async () => { - throw new Error("Password changes are unavailable for local dev identities."); - }, []); - - const createUser = useCallback( - async (input: { displayName: string; email?: string; id?: string }) => { - setLoading(true); - setError(null); - try { - const response = await configApi.createDevUser({ - id: input.id || slugFromName(input.displayName), - displayName: input.displayName, - ...(input.email ? { email: input.email } : {}), - }); - const next = dtoToIdentity(response.user); - setUsers((current) => { - const rest = current.filter((user) => user.userId !== next.userId); - return [next, ...rest]; - }); - setCurrentUser(next); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create user"); - throw err; - } finally { - setLoading(false); - } - }, - [], - ); - - const value = useMemo( - () => ({ - authMode: "dev", - authHeaders: { - Authorization: `Bearer ${currentUser.devToken}`, - "X-Workspace-Id": "default", - }, - changePassword, - createUser, - currentUser, - error, - loading, - scopeKey: currentUser.userId, - selectUser, - signOut, - signOutAll, - users, - }), - [changePassword, createUser, currentUser, error, loading, selectUser, signOut, signOutAll, users], - ); - - if (signedOut) { - return ; - } - - return ( - - {children} - - ); -} - -function DevSignedOutScreen({ onContinue }: { onContinue: () => void }) { - return ( - -
-

- Local dev mode uses a built-in account. Continue when you are ready to return to the - workspace. -

- -
-
- ); + return {children}; } function PasswordIdentityProvider({ children }: { children: ReactNode }) { @@ -335,34 +97,20 @@ function PasswordIdentityProvider({ children }: { children: ReactNode }) { await configApi.changePassword(input); }, []); - const createUser = useCallback(async (input: { displayName: string; email?: string }) => { - if (!input.email) { - throw new Error("Email is required."); - } - await configApi.register({ - email: input.email, - displayName: input.displayName, - password: "replace-this-password", - }); - }, []); - const value = useMemo(() => { if (!currentUser) return null; return { authMode: "password", authHeaders: csrfAuthHeaders(), changePassword, - createUser, currentUser, error, loading, scopeKey: currentUser.userId, - selectUser: () => undefined, signOut, signOutAll, - users: [currentUser], }; - }, [changePassword, createUser, currentUser, error, loading, signOut, signOutAll]); + }, [changePassword, currentUser, error, loading, signOut, signOutAll]); useEffect(() => { if (!loading && (!currentUser || !value)) { @@ -401,9 +149,6 @@ export function useDataTaskIdentity(): DataTaskIdentityContextValue { return context; } -import { LanguageToggle } from "../../i18n/LanguageToggle"; -import { useT } from "../../i18n/locale-context"; - export function DataTaskUserBar({ compact = false, onOpenSettings, diff --git a/apps/web/src/app/login/login-client.tsx b/apps/web/src/app/login/login-client.tsx index 83b97e62..7430af1c 100644 --- a/apps/web/src/app/login/login-client.tsx +++ b/apps/web/src/app/login/login-client.tsx @@ -3,26 +3,27 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { AuthFlow, PasswordAuthShell } from "../../components/auth/auth-flow"; -import { configApi, isPasswordAuthMode } from "../../lib/config-api/client"; +import { configApi } from "../../lib/config-api/client"; export function LoginClient() { const router = useRouter(); const [checking, setChecking] = useState(true); + const [registrationEnabled, setRegistrationEnabled] = useState(true); useEffect(() => { - if (!isPasswordAuthMode()) { - router.replace("/data-tasks"); - return; - } let cancelled = false; - configApi - .getMe() - .then(() => { - if (!cancelled) router.replace("/data-tasks"); - }) - .catch(() => { - if (!cancelled) setChecking(false); - }); + Promise.all([ + configApi.getMe().then(() => true).catch(() => false), + configApi.getAuthStatus().then((status) => status.registrationEnabled).catch(() => true), + ]).then(([signedIn, canRegister]) => { + if (cancelled) return; + if (signedIn) { + router.replace("/data-tasks"); + return; + } + setRegistrationEnabled(canRegister); + setChecking(false); + }); return () => { cancelled = true; }; @@ -32,5 +33,11 @@ export function LoginClient() { return ; } - return router.replace("/data-tasks")} />; + return ( + router.replace("/data-tasks")} + /> + ); } diff --git a/apps/web/src/app/register/register-client.tsx b/apps/web/src/app/register/register-client.tsx index 2fa598e2..dde0b747 100644 --- a/apps/web/src/app/register/register-client.tsx +++ b/apps/web/src/app/register/register-client.tsx @@ -3,26 +3,27 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { AuthFlow, PasswordAuthShell } from "../../components/auth/auth-flow"; -import { configApi, isPasswordAuthMode } from "../../lib/config-api/client"; +import { configApi } from "../../lib/config-api/client"; export function RegisterClient() { const router = useRouter(); const [checking, setChecking] = useState(true); + const [registrationEnabled, setRegistrationEnabled] = useState(true); useEffect(() => { - if (!isPasswordAuthMode()) { - router.replace("/data-tasks"); - return; - } let cancelled = false; - configApi - .getMe() - .then(() => { - if (!cancelled) router.replace("/data-tasks"); - }) - .catch(() => { - if (!cancelled) setChecking(false); - }); + Promise.all([ + configApi.getMe().then(() => true).catch(() => false), + configApi.getAuthStatus().then((status) => status.registrationEnabled).catch(() => true), + ]).then(([signedIn, canRegister]) => { + if (cancelled) return; + if (signedIn) { + router.replace("/data-tasks"); + return; + } + setRegistrationEnabled(canRegister); + setChecking(false); + }); return () => { cancelled = true; }; @@ -32,5 +33,28 @@ export function RegisterClient() { return ; } - return router.replace("/data-tasks")} />; + if (!registrationEnabled) { + return ( + + + + ); + } + + return ( + router.replace("/data-tasks")} + /> + ); } diff --git a/apps/web/src/components/auth/auth-flow.tsx b/apps/web/src/components/auth/auth-flow.tsx index fb38e69d..e5dc2df4 100644 --- a/apps/web/src/components/auth/auth-flow.tsx +++ b/apps/web/src/components/auth/auth-flow.tsx @@ -27,10 +27,12 @@ export function AuthFlow({ initialMode, onAuthenticated, error = null, + registrationEnabled = true, }: { initialMode: AuthMode; onAuthenticated: () => void | Promise; error?: string | null; + registrationEnabled?: boolean; }) { const router = useRouter(); const [mode, setMode] = useState(initialMode); @@ -194,17 +196,24 @@ export function AuthFlow({ - + ); } function AuthModeSwitch({ mode, + registrationEnabled, onGoLogin, onGoRegister, }: { mode: AuthMode; + registrationEnabled: boolean; onGoLogin: () => void; onGoRegister: () => void; }) { @@ -221,7 +230,11 @@ function AuthModeSwitch({ return (
{mode === "login" ? ( -

New to DataFoundry? {link("Create an account", onGoRegister)}

+ registrationEnabled ? ( +

New to DataFoundry? {link("Create an account", onGoRegister)}

+ ) : ( +

Registration is closed. Contact your deployment administrator.

+ ) ) : mode === "register" ? (

Already have an account? {link("Sign in", onGoLogin)}

) : ( diff --git a/apps/web/src/lib/config-api/client.ts b/apps/web/src/lib/config-api/client.ts index 8766f957..6e1ef2ea 100644 --- a/apps/web/src/lib/config-api/client.ts +++ b/apps/web/src/lib/config-api/client.ts @@ -11,8 +11,6 @@ import type { DatasourceSchemaDto, DatasourceTablePreviewDto, DatasourceTypeDto, - DevIdentitiesResponseDto, - DevIdentityUser, FileAssetRefDto, JobDto, KnowledgeBaseDto, @@ -34,14 +32,11 @@ import type { } from "./types"; import { ConfigApiError as ConfigApiErrorClass } from "./types"; -const DEFAULT_BASE_URL = "http://127.0.0.1:8787"; -const DEFAULT_WORKSPACE_ID = "default"; export type ConfigApiIdentity = { userId: string; displayName?: string; email?: string; - devToken: string; }; let currentIdentity: ConfigApiIdentity | null = null; @@ -55,49 +50,23 @@ export function clearConfigApiIdentity(): void { } export function configApiIdentityHeaders(): Record { - if (isPasswordAuthMode()) { - return {}; - } - if (!currentIdentity?.devToken) { - return {}; - } - return { - Authorization: `Bearer ${currentIdentity.devToken}`, - "X-Workspace-Id": DEFAULT_WORKSPACE_ID, - }; + return {}; } export function getConfigApiBaseUrl(): string { - if (isPasswordAuthMode()) { - const configured = process.env.NEXT_PUBLIC_CONFIG_API_URL; - if (configured !== undefined) { - return configured.replace(/\/$/u, ""); - } - return ""; + const configured = process.env.NEXT_PUBLIC_CONFIG_API_URL; + if (configured !== undefined) { + return configured.replace(/\/$/u, ""); } - return ( - process.env.NEXT_PUBLIC_CONFIG_API_URL ?? - process.env.NEXT_PUBLIC_AGENT_RUNTIME_URL?.replace(/\/api\/copilotkit\/?$/u, "") ?? - DEFAULT_BASE_URL - ).replace(/\/$/u, ""); -} - -export function isPasswordAuthMode(): boolean { - return process.env.NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE === "password"; + return ""; } export function getAgentRuntimeUrl(): string { - if (isPasswordAuthMode()) { - return "/api/copilotkit"; - } - return ( - process.env.NEXT_PUBLIC_AGENT_RUNTIME_URL ?? - `${DEFAULT_BASE_URL}/api/copilotkit` - ); + return "/api/copilotkit"; } function csrfHeader(method: string | undefined): Record { - if (!isPasswordAuthMode() || !isUnsafeMethod(method)) { + if (!isUnsafeMethod(method)) { return {}; } const token = csrfCookie(); @@ -145,7 +114,7 @@ async function requestEnvelope( const identityHeaders = configApiIdentityHeaders(); const response = await fetch(`${baseUrl}${path}`, { ...init, - ...(isPasswordAuthMode() ? { credentials: "same-origin" as RequestCredentials } : {}), + credentials: "same-origin", headers: { Accept: "application/json", ...identityHeaders, @@ -170,7 +139,7 @@ async function requestRaw(path: string, init?: RequestInit): Promise { const baseUrl = getConfigApiBaseUrl(); const response = await fetch(`${baseUrl}${path}`, { ...init, - ...(isPasswordAuthMode() ? { credentials: "same-origin" as RequestCredentials } : {}), + credentials: "same-origin", headers: { ...configApiIdentityHeaders(), ...csrfHeader(init?.method), @@ -252,15 +221,8 @@ export const configApi = { return requestEnvelope("/api/v1/me"); }, - getDevIdentities(): Promise { - return requestEnvelope("/api/v1/dev/identities"); - }, - - createDevUser(body: { id?: string; email?: string; displayName?: string }): Promise<{ user: DevIdentityUser }> { - return requestEnvelope<{ user: DevIdentityUser }>("/api/v1/dev/users", { - method: "POST", - body: JSON.stringify(body), - }); + getAuthStatus(): Promise<{ publicBaseUrl: string; registrationEnabled: boolean }> { + return requestEnvelope<{ publicBaseUrl: string; registrationEnabled: boolean }>("/api/v1/auth/status"); }, getCapabilities(): Promise { diff --git a/apps/web/src/lib/config-api/index.ts b/apps/web/src/lib/config-api/index.ts index f2f366b3..0c7e4832 100644 --- a/apps/web/src/lib/config-api/index.ts +++ b/apps/web/src/lib/config-api/index.ts @@ -44,8 +44,6 @@ export type { DatasourceTablePreviewColumnDto, DatasourceTypeDto, DatasourceTypeParamDto, - DevIdentitiesResponseDto, - DevIdentityUser, FileAssetRefDto, IdentityWorkspace, JobDto, diff --git a/apps/web/src/lib/config-api/types.ts b/apps/web/src/lib/config-api/types.ts index 3d2b96f1..08e19ee5 100644 --- a/apps/web/src/lib/config-api/types.ts +++ b/apps/web/src/lib/config-api/types.ts @@ -39,11 +39,10 @@ export class ConfigApiError extends Error { } } -export type DevIdentityUser = { +export type AuthUserDto = { id: string; email?: string; displayName?: string; - devToken?: string; }; export type IdentityWorkspace = { @@ -52,13 +51,7 @@ export type IdentityWorkspace = { }; export type MeResponseDto = { - user: DevIdentityUser; - workspace: IdentityWorkspace; -}; - -export type DevIdentitiesResponseDto = { - users: DevIdentityUser[]; - currentUserId: string; + user: AuthUserDto; workspace: IdentityWorkspace; }; diff --git a/docs/README.md b/docs/README.md index 78d89e86..e035449d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ Engineering contracts, historical design notes, and internal documentation with - Public docs must not link to internal documentation areas. - When adding external-facing capabilities, update both `en/` and `zh/` documentation. -- Example credentials must use placeholder values such as `replace-with-your-key` or ``. +- Example credentials must use placeholder values such as `replace-with-your-key`. - Do not retain source-sensitive narrative. - After doc changes, run `npm run docs:build` and `npm run smoke:docs`. diff --git a/docs/en/architecture/overview.md b/docs/en/architecture/overview.md index 07e16913..0b2b9635 100644 --- a/docs/en/architecture/overview.md +++ b/docs/en/architecture/overview.md @@ -91,9 +91,9 @@ This keeps left-panel workspace configuration, per-conversation selection, and b ## Identity scope -Local development uses dev tokens and the `default` workspace. Web v1 keeps workspace switching out of the UI and scopes browser state by user. REST configuration requests and CopilotKit AG-UI runs must use the same identity headers. +DataFoundry only supports cookie-based password sessions. Each registered user gets a personal workspace; Web v1 keeps workspace switching out of the UI and scopes browser state by user. REST configuration requests and CopilotKit AG-UI runs must use the same authenticated session. -Password mode adds cookie-based sessions, CSRF checks for unsafe methods, account registration, login, password reset, and session revocation under `/api/v1/auth/*`. +`/api/v1/auth/*` covers registration, login, email verification, password reset, logout, and session revocation. Unsafe methods require CSRF checks. ## Files, knowledge bases, and outputs @@ -105,7 +105,7 @@ Artifacts are managed by the Artifact service—common types include tables, cha ## Formal deploy boundary -The default path is formal mode (`password` + `build` / `start`); do not run `npm run dev`. Formal test and real production share the same start commands: +The default path is formal deploy (`build` / `start`); do not run `npm run dev`. Formal test and real production share the same start commands: ```bash npm run build && npm run build:web diff --git a/docs/en/capabilities.md b/docs/en/capabilities.md index 2b158377..1e4c67a1 100644 --- a/docs/en/capabilities.md +++ b/docs/en/capabilities.md @@ -9,8 +9,7 @@ Status is based on current code: | Ready to try | After local startup, with a model key configured, the built-in DTC Growth Review runs end to end. | | Requires configuration | Feature entry exists but needs a model key, database credentials, files, MCP server, or Skill package. | | Capability-controlled | Read `GET /api/v1/capabilities` and enable or hide related entry points from the response. | -| Local development boundary | Default local identity and default workspace work out of the box; Web can switch local dev users for isolation testing. | -| Password auth boundary | Built-in password mode covers account registration, login, reset, session cookies, and CSRF. Production deployments still need secret management, audit export, access control policy, and operations monitoring. | +| Password auth boundary | Cookie-based password sessions cover account registration, login, reset, and CSRF. Production deployments still need secret management, audit export, access control policy, and operations monitoring. | ## Overview @@ -31,7 +30,7 @@ Status is based on current code: | Checkpoint branches | Ready to try | No branch controls | Ready to try | Re-ask from an earlier turn or `POST /api/v1/sessions/:id/branches`. | | Evidence references | Ready to try | No selection UI | Ready to try | Reference an output or selection and inspect `evidenceRefs` diagnostics. | | Data Link graph | Requires configuration | No graph view | Requires configuration | Configure a compatible Data Link MCP server, then open **Data Link**. | -| User identity | Local dev switcher and password auth screens | Uses backend identity | Ready to try | `GET /api/v1/me`, `/api/v1/dev/*`, `/api/v1/auth/*`. | +| User identity | Password login / register screens | Uses backend session identity | Ready to try | `GET /api/v1/me`, `/api/v1/auth/*`. | | Workspace files | Upload, view, download, delete, reuse | Use enabled files via run_config | Capability-controlled | `files`, `GET/POST /api/v1/files`, `POST /api/v1/files/:id/promote`. | | Chat attachments | Ready to try | No attachment upload command | Capability-controlled | `chat.fileUpload`, `POST /api/v1/chat/uploads`. | | Image input | Input controlled by switch | No image input command | Capability-controlled | `chat.imageInput`. | @@ -115,7 +114,7 @@ Integrators should manage resources through the configuration API and start anal - Clients must not put database passwords, model API keys, or MCP tokens in the agent run body. - Read APIs do not return plaintext credentials. - SQL execution applies read-only limits, row limits, timeouts, and audit. -- Local development identity is for trials and integration development only. -- Password auth handles user sessions; production deployment still needs secret management, audit export, access control policy, and operations monitoring. +- All product paths require an authenticated password session; there is no anonymous or development-token identity. +- Production deployment still needs secret management, audit export, access control policy, and operations monitoring. Continue with [Security](security.md). diff --git a/docs/en/guides/tui.md b/docs/en/guides/tui.md index b8b04623..ea33e258 100644 --- a/docs/en/guides/tui.md +++ b/docs/en/guides/tui.md @@ -4,11 +4,7 @@ This guide is for terminal users, remote server users, and developers. After rea ## How to start -Start the full dev stack or backend first: - -```bash -npm run dev -``` +Start the API in formal mode first ([Quick start](../quick-start.md): `npm run start:api` or `./deploy.sh deploy`). Do not treat `npm run dev` as the formal entry; contributor hot-reload is in the Quick start appendix. Start the TUI: @@ -141,7 +137,7 @@ Use the Web workbench for full visual demos. Use the TUI to verify agent runtime ## Troubleshooting -- Cannot connect: confirm `npm run dev` or `npm run dev:api` is running. +- Cannot connect: confirm the formal API (`npm run start:api` or one-click deploy) is running; use `npm run dev:api` only for contributor hot-reload. - Backend URL changed: pass full `/api/copilotkit` URL with `--runtime-url`. - Model not responding: check `LLM_PROVIDER`, `LLM_MODEL`, `LLM_BASE_URL`, and `LLM_API_KEY` in root `.env`. - Session restore fails: confirm `/api/v1/sessions` is reachable and that you are signed in against the same `--runtime-url`. diff --git a/docs/en/guides/web-workbench.md b/docs/en/guides/web-workbench.md index 2703cec3..2064100e 100644 --- a/docs/en/guides/web-workbench.md +++ b/docs/en/guides/web-workbench.md @@ -23,7 +23,6 @@ Then go to `/data-tasks` after sign-in. Formal frontend settings (`apps/web/.env.local`; re-run `build:web` after changing `NEXT_PUBLIC_*`): ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= API_PROXY_TARGET=http://127.0.0.1:8787 @@ -83,7 +82,7 @@ The left panel manages two kinds of content: | --- | --- | | Data tasks | Create, switch, rename, pin, and delete tasks. | | Workspace resources | Manage data sources, knowledge bases, agent tools, and assets. | -| User menu | View the current user, open settings, sign out, or switch local development users. | +| User menu | View the current user, open settings, or sign out. | Resource entry points: diff --git a/docs/en/quick-start.md b/docs/en/quick-start.md index b4a7ff3d..e7485d6c 100644 --- a/docs/en/quick-start.md +++ b/docs/en/quick-start.md @@ -135,9 +135,9 @@ LLM_API_KEY=your-api-key Root `.env`: ```bash -DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 +AUTH_REGISTRATION_MODE=open AUTH_EMAIL_DELIVERY=test AUTH_EMAIL_FROM=DataFoundry # SMTP settings can stay empty for now @@ -146,7 +146,6 @@ AUTH_EMAIL_FROM=DataFoundry `apps/web/.env.local` (baked in at `next build`): ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password # Leave empty so the browser uses the same-origin BFF (Cookie + CSRF) NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= @@ -160,9 +159,9 @@ On register / password reset, copy the verification link from the **API process Start from the formal-test settings, then change to: ```bash -DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=https://datafoundry.example.com +AUTH_REGISTRATION_MODE=closed AUTH_EMAIL_DELIVERY=smtp AUTH_EMAIL_FROM=DataFoundry AUTH_SMTP_HOST=smtp.example.com @@ -172,7 +171,7 @@ AUTH_SMTP_USER= AUTH_SMTP_PASSWORD= ``` -Keep the frontend on `password`, empty public API URLs, and `API_PROXY_TARGET`. Put a reverse proxy in front; see [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example) — compress static assets; keep `/api/copilotkit` uncompressed and unbuffered for SSE. +Keep empty public API URLs and `API_PROXY_TARGET`. Put a reverse proxy in front; see [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example) — compress static assets; keep `/api/copilotkit` uncompressed and unbuffered for SSE. ### 3. Build and start (same for formal test and real production) @@ -306,6 +305,14 @@ If the health check fails: - **Formal test** (`AUTH_EMAIL_DELIVERY=test`): copy the link from the `start:api` terminal. - **Real production** (`smtp`): check `AUTH_SMTP_*` and that `AUTH_PUBLIC_BASE_URL` matches the public origin. +### `METADATA_SCHEMA_INCOMPATIBLE` after upgrade + +Symptom: API fails to start with `METADATA_SCHEMA_INCOMPATIBLE` and mentions `users.dev_token`. + +Cause: an older Metadata DB still has the development-token column. Password-only cutover does not migrate it. + +Fix: stop the stack, reset (or repoint) `STORAGE_ROOT_DIR` / `METADATA_DB_PATH` / `MASTRA_STORAGE_PATH` / `FILE_ASSET_STORAGE_ROOT` / `WORKSPACE_ROOT`, restart, and register again. Details: [Security](security.md). + ### Model unavailable Symptom: Agent run reports provider, 401, rate limit, or model not found errors. @@ -339,14 +346,13 @@ Stop the conflicting process, or use the port shown in the terminal. After chang For local code changes with hot reload only — **not** formal test or real production. Pick one stack; never mix with `start:*`. -```bash -# Root .env -DATAFOUNDRY_AUTH_MODE=dev - -# apps/web/.env.local -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=dev -NEXT_PUBLIC_AGENT_RUNTIME_URL=http://127.0.0.1:8787/api/copilotkit +Contributor hot-reload still uses password sessions; the old development-token auth switch is gone. +Root `.env` needs `AUTH_SESSION_SECRET` / `AUTH_PUBLIC_BASE_URL` / +`AUTH_REGISTRATION_MODE` / `AUTH_EMAIL_DELIVERY` (the formal-test sample works). +In `apps/web/.env.local`, leave `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL` empty +and set `API_PROXY_TARGET=http://127.0.0.1:8787`. +```bash npm run dev # or: npm run dev:api && npm run dev:web ``` diff --git a/docs/en/reference/configuration-api.md b/docs/en/reference/configuration-api.md index f4c10887..9f146934 100644 --- a/docs/en/reference/configuration-api.md +++ b/docs/en/reference/configuration-api.md @@ -33,24 +33,16 @@ effectiveRunConfig = merge(workspaceDefaults, perRunOverrides, serverPolicy) The backend merges these into an immutable snapshot before handing off to Agent Runtime. -## Local development auth +## Auth -```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -When headers are omitted, the backend uses the development default identity and default workspace. Web v1 treats one user as owning `default`; it does not expose workspace switching. - -Use the same identity headers for configuration API calls and AG-UI runs: +Configuration API calls and AG-UI runs must share the same password session (`df_session` cookie). Unsafe methods also need `X-CSRF-Token` from the `df_csrf` cookie: ```text -REST /api/v1/* -> Authorization / X-Dev-Token / X-Workspace-Id -CopilotKit /api/copilotkit -> Authorization / X-Dev-Token / X-Workspace-Id +REST /api/v1/* -> Cookie session + X-CSRF-Token (unsafe methods) +CopilotKit /api/copilotkit -> Cookie session + X-CSRF-Token (unsafe methods) ``` -This keeps workspace defaults, server sessions, file assets, artifacts, SQL audit, and run history in one user scope. In password auth mode, cookies identify the user and unsafe requests also send `X-CSRF-Token`. +This keeps workspace defaults, server sessions, file assets, artifacts, SQL audit, and run history in one user scope. Web v1 does not expose workspace switching. ## Common resource fields diff --git a/docs/en/reference/rest-api.md b/docs/en/reference/rest-api.md index 17417246..f84c6032 100644 --- a/docs/en/reference/rest-api.md +++ b/docs/en/reference/rest-api.md @@ -35,41 +35,26 @@ File downloads and artifact downloads return binary responses; upload endpoints ## Identity and auth -Local development supports these headers: +Only password session cookies and CSRF are supported. Business requests need the `df_session` cookie from login; unsafe methods also need `X-CSRF-Token` from the `df_csrf` cookie: ```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -When headers are omitted, the backend uses the development default identity and default workspace. Web v1 does not expose workspace switching; use `default` unless you are building an integration that owns workspace routing. - -For local user isolation, send the same identity headers to both `/api/v1/*` REST calls and `POST /api/copilotkit`. If the two channels use different headers, sessions, resources, files, artifacts, and run events can appear under different users. - -Password mode uses session cookies and CSRF: - -```text -DATAFOUNDRY_AUTH_MODE=password X-CSRF-Token: ``` -Use `DATAFOUNDRY_AUTH_MODE=dev` only for contributor hot-reload. Formal test and real production default to `password` unless overridden. +Web v1 does not expose workspace switching; custom integrations should use the workspace bound to the login session unless they manage workspace routing themselves. `/api/v1/*` and `POST /api/copilotkit` must share the same session, or sessions, resources, files, artifacts, and run events can appear under different users. ## Identity endpoints | Method | Path | Purpose | | --- | --- | --- | | GET | `/api/v1/me` | Read the current user and workspace. | -| GET | `/api/v1/dev/identities` | List local development users. Disabled in production by default. | -| POST | `/api/v1/dev/users` | Create or update a local development user. Disabled in production by default. | ## Password auth endpoints -These endpoints are enabled when password auth mode is active: | Method | Path | Purpose | | --- | --- | --- | +| GET | `/api/v1/auth/status` | Read public auth status (`registrationEnabled`; no secrets). | | POST | `/api/v1/auth/register` | Create a user account and verification token. | | POST | `/api/v1/auth/login` | Sign in and set `df_session` and `df_csrf` cookies. | | POST | `/api/v1/auth/verify-email` | Verify an email token. | diff --git a/docs/en/security.md b/docs/en/security.md index f3bc2ec7..f9d5075d 100644 --- a/docs/en/security.md +++ b/docs/en/security.md @@ -9,7 +9,6 @@ Public docs and examples must use placeholder values only: ```text replace-with-your-key your-api-key - ``` Do not put real model keys, database passwords, MCP tokens, private keys, cookies, personal access tokens, or internal network addresses in README, docs, issue examples, or screenshots. @@ -59,31 +58,11 @@ When creating resources through REST API, put credentials in resource configurat - Use allowlists for sensitive databases and tables. - SQLite, CSV, Excel, and DuckDB file paths must be accessible to the backend process. -## Local development boundaries +## Identity and sessions -Local development APIs accept dev tokens and the default workspace: +DataFoundry only supports cookie-based password sessions. Dev tokens, `/api/v1/dev/*`, and `DATAFOUNDRY_AUTH_MODE` are removed. -```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -When headers are omitted, the backend uses the development default identity and default workspace. Web v1 treats one user as owning `default`; it does not expose workspace switching. If you build a client, send the same identity headers to REST `/api/v1/*` calls and CopilotKit `/api/copilotkit` runs so configuration, sessions, files, artifacts, and run history stay in the same user scope. - -Development identity endpoints: - -| Method | Path | Purpose | -| --- | --- | --- | -| GET | `/api/v1/me` | Read the current user and workspace. | -| GET | `/api/v1/dev/identities` | List local development users. | -| POST | `/api/v1/dev/users` | Create or update a local development user. | - -`/api/v1/dev/*` is disabled in production unless `DATAFOUNDRY_ENABLE_DEV_IDENTITY_API=true`. - -## Password authentication mode - -Set `DATAFOUNDRY_AUTH_MODE=password` for cookie-based password authentication. Formal mode uses `password` by default. Required settings: +Required settings: ```text AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters @@ -93,7 +72,7 @@ AUTH_EMAIL_DELIVERY=test AUTH_EMAIL_FROM=DataFoundry ``` -`AUTH_REGISTRATION_MODE` is required in password mode (`open` = self-register, `closed` = reject register with `REGISTRATION_CLOSED`). Deploy defaults to `open` for formal local/test; set `closed` for internet-facing installs that should not accept public signup. `GET /api/v1/auth/status` exposes `registrationEnabled` without secrets. +`AUTH_REGISTRATION_MODE` is required (`open` = self-register, `closed` = reject register with `REGISTRATION_CLOSED`). Deploy defaults to `open` for formal local/test; set `closed` for internet-facing installs that should not accept public signup. `GET /api/v1/me` returns the current user; `GET /api/v1/auth/status` exposes `registrationEnabled` without secrets. Two formal environments share the same start commands: @@ -102,9 +81,13 @@ Two formal environments share the same start commands: | Formal test | `test` (links in API console) | Loopback HTTP URL | `open` | | Real production | `smtp` (plus `AUTH_SMTP_*`) | Public HTTPS origin | `closed` unless self-signup is intentional | -Password mode adds `/api/v1/auth/*` endpoints for registration, login, email verification, password reset, logout, session listing, and password change. Unsafe requests require `X-CSRF-Token` from the `df_csrf` cookie. The session cookie is `df_session`. Cookie `Path` / `Secure` follow `AUTH_PUBLIC_BASE_URL` (HTTPS ⇒ `Secure`; pathname prefix becomes cookie path). `AUTH_EMAIL_DELIVERY=test` is rejected unless `AUTH_PUBLIC_BASE_URL` is loopback. +`/api/v1/auth/*` covers registration, login, email verification, password reset, logout, session listing, and password change. Unsafe requests require `X-CSRF-Token` from the `df_csrf` cookie. The session cookie is `df_session`. Cookie `Path` / `Secure` follow `AUTH_PUBLIC_BASE_URL` (HTTPS ⇒ `Secure`; pathname prefix becomes cookie path). `AUTH_EMAIL_DELIVERY=test` is rejected unless `AUTH_PUBLIC_BASE_URL` is loopback. + +Leave `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL` empty so the browser uses the same-origin Next BFF; point the upstream API with `API_PROXY_TARGET` in `apps/web/.env.local`. Start with `npm run build && npm run build:web && npm run start:api && npm run start:web`. Real-production reverse-proxy sample: `deploy/nginx.datafoundry.conf.example`. + +If an old `.env` still has `DATAFOUNDRY_AUTH_MODE=password`, the API ignores that value; `./deploy.sh deploy` strips it from written config. `DATAFOUNDRY_AUTH_MODE=dev` fails startup. -Also set `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password` and leave `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL` empty so the browser uses the same-origin Next BFF; point the upstream API with `API_PROXY_TARGET` in `apps/web/.env.local`. Start with `npm run build && npm run build:web && npm run start:api && npm run start:web`. Real-production reverse-proxy sample: `deploy/nginx.datafoundry.conf.example`. +Password-only does **not** migrate Metadata databases that still have `users.dev_token`. Opening such a DB fails with `METADATA_SCHEMA_INCOMPATIBLE`. Stop the stack, reset `STORAGE_ROOT_DIR` / `METADATA_DB_PATH` / `MASTRA_STORAGE_PATH` / `FILE_ASSET_STORAGE_ROOT` / `WORKSPACE_ROOT` (or point them at empty paths), restart, and register again. There is no in-place schema upgrade for this cutover. Real production also needs secret management, audit export, access control, and operations monitoring. diff --git a/docs/engineering/event-contract-gap-inventory.md b/docs/engineering/event-contract-gap-inventory.md deleted file mode 100644 index 73a5b221..00000000 --- a/docs/engineering/event-contract-gap-inventory.md +++ /dev/null @@ -1,63 +0,0 @@ -# Event Contract Gap Inventory - -Diagnosis for the dataagent contract-governance plan (phase 0). Maps observed gaps between Mastra agent loop output and what the frontend needs for pure rendering. - -## Loop termination - -| Observation | Location | Gap | -|-------------|----------|-----| -| Agent uses `maxSteps: AGENT_MAX_STEPS` | `packages/agent-runtime/src/index.ts` | Model may end immediately after a tool call without a closing user-facing text message. | -| `finish-step` chunks are dropped | `packages/agent-runtime/src/stream/mastra-stream-normalizer.ts` | No structured "run closing summary" event when the model skips final text. | -| Prior fix: `RunCompletionAnswerTracker` | removed from `apps/api/src/server.ts` | Was injecting hard-coded Chinese/English closing text. Replaced by prompt policy requiring a natural-language closing message. | - -**Stop reasons (Mastra / AG-UI):** `RUN_FINISHED` and `RUN_ERROR` are the terminal AG-UI events. Mastra may finish a step without streaming assistant text when the last model action is a tool call. No distinct "max steps" event is surfaced to the client today. - -**Remediation (phase 1):** Prompt policy in `buildAgentInstructions` requires a closing summary. Fallback: structured completion event from `RunFinalizer.complete()` if model silence persists. - -## TOOL_CALL_RESULT delivery - -| Tool family | TOOL_CALL_END | TOOL_CALL_RESULT (raw Mastra stream) | ACTIVITY STEP snapshot | Bridge backfill | -|-------------|---------------|--------------------------------------|------------------------|-----------------| -| Data tools (`inspect_schema`, `run_sql_readonly`, …) | Yes | **Missing** | Yes (`data-tools.ts`) | Yes (`tool-call-result-bridge.ts`) | -| Workspace tools (`write_file`, `execute_command`, …) | Yes | Usually yes (Mastra `tool-result` chunk) | Via `data-workspace-metadata` CUSTOM | Rarely needed | -| Collaboration (`ask_user`, `submit_plan`) | Yes | Yes when suspended/completed | No | No | -| `publish_artifact` | Yes | Yes | Artifact CUSTOM event | No | - -**Root cause:** `@ag-ui/mastra` maps Mastra `tool-result` chunks to `TOOL_CALL_RESULT`. Data tools emit governed observations through `ACTIVITY_SNAPSHOT` only; the stream never carries `tool-result` for them. - -**Remediation (phase 2):** Emit `TOOL_CALL_RESULT` at the governed tool execution boundary (`GovernedToolFactory`). Keep `ToolCallResultBridge` with warn-only logging during transition. - -## Artifact dual path (resolved in phase 3) - -| Path | Before | After | -|------|--------|-------| -| Write file → auto artifact | `workspace-artifact-recorder.ts` + `artifact-publish-policy.ts` | **Removed** | -| Write file → session file ref | `RunFinalizer.syncSessionOutputs` | Unchanged | -| Client-visible artifact | `publish_artifact` tool only | **Single authority** | - -## Checkpoint / restore heuristics (phase 4 targets) - -Frontend `conversation-restore.ts` still infers when backend DTO fields are incomplete: - -| Heuristic | Trigger | Authoritative backend field needed | -|-----------|---------|-----------------------------------| -| `hydratedSegmentStatus` without checkpoint | Guesses from tools / hasAssistant | `checkpoints[].status` for every run | -| `inferCollaborationToolNameFromToolCall` | `toolName` missing or `ask_user` with plan args | `toolCalls[].toolName` resolved server-side | -| `workspacePathFromToolResult` | Regex on tool result text | `restorableCustomEvents` (`workspace.metadata`) | -| `deriveWorkspaceSignalsFromToolCalls` | Missing CUSTOM events on restore | Persisted `workspace.metadata` / `sandbox.output` | -| `reconcileLiveRunArtifacts` | Artifact missing `createdByEventId` | Still needed for SQL table artifacts from `publish_artifact` / gateway | - -## Custom event persistence - -Restorable CUSTOM event names (`config-api.ts`): `token_usage`, `token_usage.correlation`, `workspace.metadata`, `sandbox.output`, `goal.updated`, `sql_audit`. - -Workspace `data-*` stream chunks are mapped in `mastra-stream-hooks.ts` → CUSTOM events → persisted → replayed by `replayRestorableCustomEvents`. - -## Verification commands - -```bash -npm run build -npm run test:web -node scripts/diagnose-tool-result-events.mjs # needs LLM_API_KEY -node scripts/smoke-conversation-memory.mjs -``` diff --git a/docs/plans/2026-07-10-semantic-trace-sections.md b/docs/plans/2026-07-10-semantic-trace-sections.md deleted file mode 100644 index c34e095e..00000000 --- a/docs/plans/2026-07-10-semantic-trace-sections.md +++ /dev/null @@ -1,77 +0,0 @@ -# Semantic Trace Sections Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Turn long task traces into durable, asynchronously generated semantic sections that can be collapsed in the embedded and full-screen Trace DAG views. - -**Architecture:** Persist section jobs and completed sections next to the existing run events/checkpoints. A run-scoped coordinator observes the durable event stream, invokes the same resolved model provider used by the task outside the agent response path, and projects section metadata with the existing Trace DAG response. The web app shares one section-aware viewer between the Trace tab and full-screen overlay. - -**Tech Stack:** TypeScript, SQLite metadata store, existing OpenAI-compatible model provider, Node API runtime, Next.js/React, SVG Trace DAG canvas. - ---- - -### Task 1: Persist semantic section state - -**Files:** -- Modify: `packages/metadata/src/index.ts` -- Test: `packages/metadata/src/index.test.ts` or focused metadata smoke coverage - -1. Add section and job record/input types, repositories, `MetadataStore` accessors, SQLite schema, and one forward migration. -2. Scope every record by user, session, branch, and run; store start/end event sequences, title, summary, status, and timestamps. -3. Add de-duplicated pending-job creation and terminal job updates so restarts do not lose work. -4. Verify repository reads preserve branch isolation and event ordering. - -### Task 2: Summarize trace ranges asynchronously - -**Files:** -- Create: `apps/api/src/trace-section-coordinator.ts` -- Modify: `apps/api/src/run-event-pipeline.ts` -- Modify: `apps/api/src/server.ts` -- Modify: `apps/api/src/run-finalizer.ts` -- Test: `scripts/smoke-trace-sections.mjs` - -1. Observe persisted envelopes after the existing checkpoint projection; schedule work after three eligible context/tool steps and force a final pass when a run terminates. -2. Build a bounded, redacted event representation for the model rather than forwarding raw tool payloads. -3. Invoke the run's resolved model provider with a structured JSON prompt that returns title, summary, covered range, and completion state. -4. Serialize jobs per run, retry failures, and ensure errors never delay streaming, persistence, or finalization. -5. Run a real DeepSeek end-to-end smoke test with the configured provider, not a fake LLM. - -### Task 3: Extend the Trace DAG contract - -**Files:** -- Modify: `apps/api/src/trace-dag.ts` -- Modify: `apps/api/src/config-api.ts` -- Modify: `apps/web/src/lib/config-api/types.ts` -- Modify: `apps/web/src/lib/config-api/client.ts` -- Test: `scripts/smoke-trace-sections.mjs` - -1. Add `TraceDagSectionDto` to the response alongside untouched node/edge data. -2. Resolve visible sections through the same session lineage and map each section to the exact underlying node ids. -3. Preserve backwards-compatible behavior for sessions without sections. -4. Verify branch forks never expose sections from hidden future parent events. - -### Task 4: Reuse the viewer in Trace and overlay - -**Files:** -- Create: `apps/web/src/app/data-tasks/components/task-console/TraceDagViewer.tsx` -- Modify: `apps/web/src/app/data-tasks/components/task-console/TraceDagCanvas.tsx` -- Modify: `apps/web/src/app/data-tasks/components/task-console/TraceOverlay.tsx` -- Modify: `apps/web/src/app/data-tasks/components/task-console/TaskConsole.tsx` -- Modify: `apps/web/src/app/data-tasks/page.tsx` -- Test: `apps/web/src/app/data-tasks/__tests__/trace-dag*.test.tsx` - -1. Move graph fetching, incremental refresh, selection, right-side detail rendering, and checkpoint branching into a reusable viewer. -2. Render the viewer directly at the top of the Trace tab; retain the full-screen button as an alternate layout using the same props and state model. -3. Make a completed section a compact graph group with title, summary signal, step count, and expand/collapse action; render underlying nodes only when expanded. -4. Keep tool labels below nodes, preserve node detail behavior, and avoid resetting the viewport on each live refresh. -5. Make the embedded layout dense and operational rather than a second modal-like surface. - -### Task 5: Verify and ship in small commits - -**Files:** -- Modify: relevant source and focused test files above - -1. Run focused API and web tests after each layer. -2. Run `npm run build` and the real DeepSeek trace-section smoke test. -3. Inspect desktop and narrow-browser Trace tab plus full-screen overlay for section collapse, details, tool labels, branch action, and refresh stability. -4. Commit coherent backend and frontend units, then push only to `wis/feat/checkpoint-trace-dag-followup`. diff --git a/docs/superpowers/plans/2026-07-09-backend-contract-hardening.md b/docs/superpowers/plans/2026-07-09-backend-contract-hardening.md deleted file mode 100644 index 79d29057..00000000 --- a/docs/superpowers/plans/2026-07-09-backend-contract-hardening.md +++ /dev/null @@ -1,43 +0,0 @@ -# Backend contract hardening 实现计划 - -> **面向 AI 代理的工作者:** 内联执行本计划。步骤使用复选框跟踪进度。 - -**目标:** 闭合 HITL / artifact / checkpoint 三处后端契约缺口,前端补丁标为 legacy。 - -**架构:** 写路径保证 suspend 时落 `TOOL_CALL_START`;读路径合成缺失 toolCalls;扩展 checkpoint `runIds`;artifact 生产者已带 `tool_call_id` 的路径对齐并标注;前端仅加注释。 - -**技术栈:** TypeScript / apps/api / apps/web / vitest / smoke scripts - ---- - -### 任务 1:HITL 写路径 — suspend 前补 TOOL_CALL_START - -**文件:** -- 修改:`apps/api/src/interaction-runtime-adapter.ts` -- 修改:`apps/api/src/server.ts` - -- [x] 导出 `buildHitlToolCallStartEvent(interrupt)` -- [x] `server.ts` 跟踪已见 `TOOL_CALL_START`;interrupt 时若缺失则 `emit` 后再 suspend - -### 任务 2:HITL 读路径 + checkpoint runIds - -**文件:** -- 修改:`apps/api/src/config-api.ts` - -- [x] `runIds` union pendingInteractions -- [x] 对缺失的 pending interaction 合成 `toolCalls` DTO(`awaitingInteraction: true`) -- [x] 无 events 的 HITL-only run 发出 suspended checkpoint - -### 任务 3:Artifact / frontend legacy 注释 - -**文件:** -- 修改:`packages/artifacts/src/index.ts`(JSDoc:必须传 tool_call_id) -- 修改:`apps/web/.../conversation-restore.ts`、`live-run-state.ts` -- 修改:`scripts/smoke-files.mjs`(chart 带 tool_call_id) - -- [x] 标注 synthetic parent / HITL bootstrap / e2e replay / artifact heuristic / user-only guess 为 legacy - -### 任务 4:测试与验证 - -- [x] 扩展 conversation-restore / smoke-config-api HITL 合成断言 -- [x] 跑 typecheck + vitest restore/live-run + smoke-config-api / conversation-memory / files diff --git a/docs/superpowers/plans/2026-07-09-tool-e2e-and-thinking-persistence.md b/docs/superpowers/plans/2026-07-09-tool-e2e-and-thinking-persistence.md deleted file mode 100644 index 1bb04187..00000000 --- a/docs/superpowers/plans/2026-07-09-tool-e2e-and-thinking-persistence.md +++ /dev/null @@ -1,27 +0,0 @@ -# Plan: Tool e2e idempotency + Thinking fold persistence - -Spec: `docs/superpowers/specs/2026-07-09-tool-e2e-and-thinking-persistence-design.md` - -## Files - -| File | Change | -|------|--------| -| `apps/web/.../live-run-state.ts` | Idempotent `finishedAtMs` on RESULT/END | -| `apps/web/.../use-data-foundry-run.tsx` | Skip completed-tool replay while restoring | -| `apps/web/.../__tests__/live-run-state.test.ts` | Duration replay regression | -| `apps/api/src/conversation-memory.ts` | Observe REASONING_MESSAGE_*; persist parts | -| `apps/api/src/config-api.ts` | Expose `contentParts` on message DTO | -| `apps/web/.../config-api/types.ts` | `contentParts` on ConversationMessageDto | -| `apps/web/.../conversation-restore.ts` | Restore parts into message content | -| `apps/web/.../agent-message-render-sync.ts` | Fingerprint reasoning length | -| Tests for memory / restore / fingerprint | As needed | -| `packages/agent-runtime` stream (if needed) | Emit REASONING_MESSAGE_* | - -## Tasks - -1. Part A — failing tests for RESULT replay preserving duration; implement idempotency + restore guard. -2. Probe whether REASONING events reach API; bridge if missing. -3. Part B — observer persists parts; DTO + restore + fingerprint; tests. -4. Run targeted web/api tests. - -No auto-commit unless user asks. diff --git a/docs/superpowers/specs/2026-07-09-backend-contract-hardening-design.md b/docs/superpowers/specs/2026-07-09-backend-contract-hardening-design.md deleted file mode 100644 index 73324ca4..00000000 --- a/docs/superpowers/specs/2026-07-09-backend-contract-hardening-design.md +++ /dev/null @@ -1,64 +0,0 @@ -# Design: Backend contract hardening (HITL / artifact / checkpoint) - -Date: 2026-07-09 -Status: Approved (user chose option A; implement now) -Scope: Close three frontend-compensated backend gaps; keep frontend fallbacks as legacy only - -## Goals - -1. HITL suspend atomically persists `interactions` + `TOOL_CALL_START` (read-path synthesis as defense). -2. Artifact producers always write `metadata_json.tool_call_id` when a tool produced the artifact. -3. Conversation checkpoints cover all terminal / suspended runs referenced by messages, interactions, or tool calls. -4. Frontend orphan / e2e / replay / HITL bootstrap / artifact heuristic paths are marked legacy / dual-channel only. - -## Non-goals - -- Delete frontend fallbacks in this change -- DB schema migration -- Artifact preview `/preview` vs `/content` contract - -## Design - -### HITL atomic contract - -**Write path (primary):** On `on_interrupt` capture in `server.ts`, before `finalizer.suspend()`: - -- Emit a synthetic `TOOL_CALL_START` for the interrupt's `toolCallId` / `toolName` / args when the run event stream has not already persisted one for that id. -- Then emit `interaction.requested` and suspend as today. - -**Read path (defense):** In `config-api.ts` `toolCallPairDtos` / session conversation assembly: - -- For each pending interaction whose `tool_call_id` is missing from event-derived tool calls, synthesize a pending tool call DTO with `awaitingInteraction: true` and authoritative `toolName`. - -Frontend `hydratePendingInteractionLiveRun` remains for old sessions; annotate as legacy. - -### Artifact `tool_call_id` - -- Audit `createChartArtifact` / report / remaining `ArtifactService.create*` call sites; pass `{ tool_call_id, step_id }` in `metadata_json`. -- Keep `createArtifactEvent` / session DTO origin extraction as-is. -- Frontend `findArtifactSourceTool` heuristics: comment as legacy fallback when authoritative id absent. - -### Checkpoint coverage - -- Expand `runIds` union in conversation assembly: messages + pendingInteractions + toolCalls + summary `source_run_id`. -- Keep `eventOnlyRunCheckpointDto` for runs with events but no `runs` row. -- Ensure early-fail / abort paths still `updateStatus(failed|canceled)`. -- Frontend `finalizeMessageOnlyHydratedRunSegment` user-only failed guess: annotate legacy; prefer checkpoint when present. - -### Legacy annotations (frontend) - -Add short comments on: - -- `insertSyntheticToolParentMessages` / `syntheticToolParentMessageId` -- `shouldSkipAgUiReplayDuringRestore` / idempotent `finishedAtMs` -- `hydratePendingInteractionLiveRun` -- `findArtifactSourceTool` heuristic branch -- `finalizeMessageOnlyHydratedRunSegment` no-checkpoint guess - -## Acceptance - -1. New HITL suspend → conversation DTO has matching `toolCalls[]` entry without frontend bootstrap. -2. New SQL / publish / chart artifacts expose `toolCallId` on REST list. -3. User-only failed run with `runs.failed` (or event-only terminal) restores via checkpoint, not guess. -4. New tool-parent runs restore without `restored-tool-parent:*`. -5. Existing unit tests for restore / live-run / smoke-conversation-memory still pass. diff --git a/docs/superpowers/specs/2026-07-09-tool-e2e-and-thinking-persistence-design.md b/docs/superpowers/specs/2026-07-09-tool-e2e-and-thinking-persistence-design.md deleted file mode 100644 index fef71f30..00000000 --- a/docs/superpowers/specs/2026-07-09-tool-e2e-and-thinking-persistence-design.md +++ /dev/null @@ -1,209 +0,0 @@ -# Design: Tool e2e duration idempotency + Thinking fold persistence - -Date: 2026-07-09 -Status: Approved (user confirmed A+B; artifact preview deferred) -Scope: dataagent web + api conversation memory - -## Problem - -Three frontend case issues were diagnosed. This design covers **(1)** and **(3)** only. - -1. **Tool e2e duration inflates on session switch** — and grows each time the user switches back. -2. **Artifact preview empty** — deferred; discuss separately (not in this change). -3. **Thinking missing / unstable** — after restore, during live run, or after a step completes. - -## Goals - -1. Completed tool step e2e duration stays stable across AG-UI replay and session restore. -2. Model reasoning/thinking survives session restore by folding into existing assistant messages (no new DB role). -3. Live run and post-step UI keep showing thinking when CopilotKit render messages are ephemeral. -4. Reasoning must **not** be fed back into the next model prompt. - -## Non-goals - -- Artifact `/preview` vs `/content` contract (issue 2). -- Extending `ConversationMessageRole` with `"reasoning"`. -- Persisting tool-level `startedAt`/`finishedAt` on the backend DTO (optional later). -- Changing Task Console canned `thought` template strings in `live-run-state`. -- Feeding reasoning into agent ingress / model context. - ---- - -## Part A — Tool e2e duration idempotency - -### Root cause - -UI e2e = `finishedAtMs - startedAtMs` on `LiveToolCallRecord`. - -On live run, both timestamps are wall-clock at START/RESULT and look correct (ms). - -On session switch-back: - -1. In-memory `liveRunsByThreadId` keeps the original `startedAtMs`. -2. CopilotKit/AG-UI replays historical `TOOL_CALL_RESULT`. -3. `reduceToolEvent` **always** sets `finishedAtMs = Date.now()` on RESULT. -4. Restore only skips run-boundary events (`RUN_STARTED` / `FINISHED` / `ERROR` / `STATE_SNAPSHOT`), not tool events. - -Result: duration ≈ now − first start, and increases on every switch-back. - -### Design - -#### A1. Idempotent terminal timestamps - -In `apps/web/src/app/data-tasks/live-run-state.ts` `reduceToolEvent`: - -- On `TOOL_CALL_RESULT`: if `existing.status` is `success` or `failed` **and** `existing.finishedAtMs` is set, **do not** overwrite `startedAtMs` / `finishedAtMs`. Result payload / failure status may still update if needed. -- On `TOOL_CALL_END`: keep current guard (`!existing?.finishedAtMs`); ensure it never clears or rewinds an existing finish time. - -#### A2. Restore-time tool replay guard - -In `apps/web/src/app/data-tasks/use-data-foundry-run.tsx` (and any equivalent subscriber): - -While `isRestoringConversation` is true: - -- Continue skipping run-boundary replay events. -- Also skip `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT` when the tool call already exists in live run with terminal status (`success` | `failed`) and `finishedAtMs`. - -Rationale: REST hydrate already rebuilt tools; AG-UI replay must not mutate timing. - -#### A3. Tests - -Add/extend web unit tests: - -1. Completed tool with `startedAtMs=1000`, `finishedAtMs=1050` → replay `TOOL_CALL_RESULT` → duration still 50ms. -2. During restore flag, completed tool ignores RESULT replay (same assertion). -3. First-time RESULT still sets `finishedAtMs` (live path unchanged). - -### Out of scope for A - -- Backend `ConversationToolCallDto` timestamps. -- Using SQL gateway `elapsed_ms` as step e2e. - ---- - -## Part B — Thinking persistence (fold into assistant) - -### Root cause (summary) - -| Symptom | Cause | -|---------|--------| -| Missing after restore | Backend only persists `TEXT_MESSAGE_*` assistant text; AG-UI `REASONING_MESSAGE_*` ignored; DTO has no parts; restore maps string `contentText` only | -| Missing during live | Render/agent dual-source; fingerprint ignores reasoning length | -| Gone after step | Ephemeral reasoning messages + collapse / `return null` when content parses empty | - -Chat Thinking comes from CopilotKit messages (`role: "reasoning"` and/or `{type:"reasoning"}` parts), **not** from `live-run-state.thought` (canned Task Console copy). - -AG-UI already defines `REASONING_MESSAGE_START|CONTENT|CHUNK|END` (`@ag-ui/core`). This codebase does not observe them today. - -### Storage contract (option 1 — fold) - -Keep `role: "assistant"`. Extend `content_json` (backward compatible): - -```json -{ - "text": "final assistant text or reasoning fallback", - "parts": [ - { "type": "reasoning", "text": "先检查 schema…" }, - { "type": "text", "text": "最终回复" } - ] -} -``` - -Rules: - -1. `parts` optional; legacy `{ "text": "..." }` unchanged. -2. `content_text` = prefer concatenated/final **text** parts; if only reasoning exists, use reasoning text so empty-draft skip does not drop the message. -3. Preserve existing fields (`evidenceRefs`, etc.). -4. Do **not** add `"reasoning"` to `ConversationMessageRole`. - -### Backend collection - -Update `apps/api/src/conversation-memory.ts` `ConversationMemoryEventObserver`: - -1. Observe `REASONING_MESSAGE_START` / `CONTENT` / `CHUNK` / `END` (and deprecated aliases if still emitted). -2. Buffer reasoning deltas by `messageId` (same draft map as text, or sibling buffer merged at flush). -3. Continue observing `TEXT_MESSAGE_*` for text. -4. On `persistAssistantDrafts`: - - Build `parts` from reasoning + text buffers. - - Persist when **either** reasoning or text is non-empty (after trim / meaningful-text policy aligned with frontend where practical). - - `content: { text, parts }` (plus any existing keys). - -Message attribution: one AG-UI `messageId` → one assistant row. If reasoning and final text use different messageIds (per-step segmentation), they become separate assistant rows; frontend `resolveToolStepThoughtContent` already walks adjacent messages. - -#### Bridge gap (implementation gate) - -If a real run’s SSE does **not** emit `REASONING_MESSAGE_*` (reasoning only appears in CopilotKit client render state), implementers must: - -1. Confirm Mastra → `@ag-ui/mastra` mapping for reasoning chunks; and/or -2. Emit/normalize `REASONING_MESSAGE_*` in `packages/agent-runtime` stream path so the API observer can see them. - -User approved allowing bridge/normalizer work **without** changing DB role (still fold into assistant). - -Reasoning must remain filtered from model ingress (`normalizeIngressMessages` already drops `role: "reasoning"`). Persisted parts must not be re-injected as model-visible reasoning turns unless explicitly designed later (default: history assembly uses `content_text` / text parts only). - -### API / DTO - -Extend conversation message DTO: - -```ts -contentParts?: Array<{ type: "reasoning" | "text"; text: string }> -``` - -- Populate from `content_json.parts` in `conversationMessageDto` (`apps/api/src/config-api.ts`). -- Keep `contentText` for compatibility. -- Frontend `ConversationMessageDto` in `apps/web/src/lib/config-api/types.ts` mirrors the field. - -### Frontend restore - -In `conversationToAgentMessages` (`conversation-restore.ts`): - -- If `contentParts` present and non-empty → set message `content` to that parts array (CopilotKit-compatible). -- Else → keep string `contentText` behavior. -- Empty text-only assistants still restored when tool-parent placeholders require them (`assistantIdsToRestore`). - -Existing `messageTextContent` / `resolveToolStepThoughtContent` already read `{type:"reasoning"}` parts. - -### Frontend live hardening (required with B) - -1. **Fingerprint** (`agent-message-render-sync.ts`): include reasoning text length (and preferably role) so render-only reasoning updates re-render steps. -2. **Step-local snapshot** (optional but recommended): when a tool step’s `processContent` / thought resolves non-empty, keep a step-scoped snapshot so collapse after `isActive=false` does not depend on ephemeral reasoning bubbles. -3. **Avoid false `return null`**: do not drop a completed step that had meaningful thinking/parts solely because live tool binding cleared and string content looks empty—prefer parts/snapshot. - -### Tests - -Backend: - -- Observer: REASONING + TEXT → `content_json.parts` correct; reasoning-only draft persists. -- Legacy TEXT-only still `{ text }` (or `parts` with only text — pick one and document; prefer writing `parts` when any structured content exists, always set `text`). - -API DTO: - -- `contentParts` round-trip from stored `content_json`. - -Frontend: - -- Restore with `contentParts` → thinking visible via `resolveToolStepThoughtContent`. -- Fingerprint changes when reasoning grows. -- e2e duration tests from Part A. - -Smoke (manual or script): - -- Run with reasoning model → switch session → thinking still in middle column; tool duration stable. - -## Acceptance criteria - -1. Live tool e2e remains millisecond-scale; switch away and back does **not** increase duration. -2. Runs that produced reasoning: after refresh / session switch, Thinking still appears in the step UI. -3. Legacy conversations without `parts` restore unchanged. -4. Reasoning is not included in the next model prompt / ingress messages. - -## Implementation order - -1. Part A (idempotent timestamps + restore guard + tests) — ship first. -2. Verify whether SSE emits `REASONING_MESSAGE_*`; if not, bridge/normalizer fix. -3. Part B observer + DTO + restore + live fingerprint/snapshot + tests. - -## Open follow-ups (not this design) - -- Artifact preview empty (`publish_artifact` / `/preview` vs `/content`). -- Optional backend tool timing fields for perfect restore without wall clock. diff --git a/docs/superpowers/specs/2026-07-09-tool-parent-persistence-design.md b/docs/superpowers/specs/2026-07-09-tool-parent-persistence-design.md deleted file mode 100644 index bec0ee9a..00000000 --- a/docs/superpowers/specs/2026-07-09-tool-parent-persistence-design.md +++ /dev/null @@ -1,45 +0,0 @@ -# Design: Authoritative tool-parent message persistence - -Date: 2026-07-09 -Status: Approved (options 1+3) -Scope: Eliminate orphan `parentMessageId` at the persistence source - -## Problem - -`TOOL_CALL_START.parentMessageId` often points at an AG-UI assistant message that never becomes a conversation row (tool-only / empty step). Restore then invents `restored-tool-parent:*` placeholders; an earlier frontend patch reordered those by `callEventSeq`, but that is not a complete fix. - -## Goals - -1. Every `toolCalls[].parentMessageId` in a new run exists as `messages[].messageId`. -2. Restore order matches live tool order without relying on synthetic orphans for new data. -3. Empty tool-parent rows are not fed into model history text. -4. Keep frontend orphan insertion as legacy fallback only. - -## Design - -### Backend (`ConversationMemoryEventObserver`) - -On `TOOL_CALL_START` with `parentMessageId`: - -- If no draft for that id, create an assistant draft with `toolParent: true`, empty text/reasoning. -- If draft exists, set `toolParent: true` (keep text/reasoning). - -On flush: - -- Persist drafts that have text, reasoning, **or** `toolParent`. -- Empty tool-parent content: `{ "text": "", "kind": "tool_parent" }`, `content_text: ""`. - -### History assembly - -Skip assistant rows whose visible text is empty and `kind === "tool_parent"` (same spirit as reasoning-only skip). - -### Live / regression (option 3) - -- Smoke: tool-only parent after `TOOL_CALL_START` persists; history excludes empty tool-parent; write→publish style parents both present. -- Frontend orphan path remains for old sessions. - -## Non-goals - -- DB role migration -- Bulk backfill of old sessions -- Removing frontend orphan fallback in this change diff --git a/docs/superpowers/specs/2026-07-10-datasource-local-file-upload-design.md b/docs/superpowers/specs/2026-07-10-datasource-local-file-upload-design.md deleted file mode 100644 index 6a636877..00000000 --- a/docs/superpowers/specs/2026-07-10-datasource-local-file-upload-design.md +++ /dev/null @@ -1,25 +0,0 @@ -# Datasource local-file upload (A1) - -Date: 2026-07-10 -Status: approved (user chose A1) - -## Problem - -Remote Web cannot use browser-machine absolute paths. File-backed datasources -(DuckDB / SQLite / CSV / Excel / Access) must accept a **local file pick + upload**, -then store a **server-readable path** on the datasource config. - -## Design - -1. UI: beside `filePath`, add “Choose local file”; on select, upload immediately. -2. API: `POST /api/v1/datasources/uploads` (multipart `file`) → write under - user workspace `datasources/` → return `{ path, originalName, size, mimeType }`. -3. Frontend writes returned `path` into `settings.filePath` and shows original name. -4. Keep existing save/test flow; path remains server-local after upload. -5. Extension allowlist by type; reject empty/oversized files. - -## Out of scope - -- Parsing browser absolute paths -- Restoring DuckDB demo datasource -- BigQuery keyFilename / KB vectorStore diff --git a/docs/zh/architecture/overview.md b/docs/zh/architecture/overview.md index 4c633a70..7c8a429a 100644 --- a/docs/zh/architecture/overview.md +++ b/docs/zh/architecture/overview.md @@ -95,9 +95,9 @@ workspace defaults ## 身份作用域 -本地开发使用 dev token 和 `default` workspace。Web v1 不在界面中暴露 workspace 切换,并按用户隔离浏览器状态。REST 配置请求和 CopilotKit AG-UI run 必须使用同一组身份头。 +DataFoundry 仅支持基于 Cookie 的密码会话。每个注册用户拥有个人 workspace;Web v1 不在界面中暴露 workspace 切换,并按用户隔离浏览器状态。REST 配置请求和 CopilotKit AG-UI run 必须使用同一已认证会话。 -password 模式在 `/api/v1/auth/*` 下提供基于 Cookie 的会话、非安全方法 CSRF 校验、账号注册、登录、密码重置和会话注销。 +`/api/v1/auth/*` 覆盖注册、登录、邮箱验证、密码重置、登出与会话注销;非安全方法需 CSRF 校验。 ## 文件、知识库和产出 @@ -109,7 +109,7 @@ password 模式在 `/api/v1/auth/*` 下提供基于 Cookie 的会话、非安全 ## 正式态部署边界 -默认部署路径是正式态(`password` + `build` / `start`),不要跑 `npm run dev`。正式测试与真实生产启动命令相同: +默认部署路径是正式态(`build` / `start`),不要跑 `npm run dev`。正式测试与真实生产启动命令相同: ```bash npm run build && npm run build:web diff --git a/docs/zh/capabilities.md b/docs/zh/capabilities.md index b92fea95..42ee2401 100644 --- a/docs/zh/capabilities.md +++ b/docs/zh/capabilities.md @@ -9,8 +9,7 @@ | 可直接试用 | 本地启动后,配好模型 Key,用内置 DTC Growth Review 可以跑通。 | | 需要配置 | 功能入口已接入,需要你提供模型 Key、数据库凭据、文件、MCP Server 或 Skill package。 | | 受 capability 控制 | 读取 `GET /api/v1/capabilities`,按返回值启用或隐藏相关入口。 | -| 本地开发边界 | 本地默认身份和默认 workspace 可用;Web 可切换本地开发用户用于隔离验证。 | -| 密码认证边界 | 内置 password 模式覆盖账号注册、登录、重置、会话 Cookie 和 CSRF;生产部署仍需 Secret 管理、审计导出、访问控制策略和运维监控。 | +| 密码认证边界 | 基于 Cookie 的密码会话覆盖账号注册、登录、重置和 CSRF;生产部署仍需 Secret 管理、审计导出、访问控制策略和运维监控。 | ## 总览 @@ -31,7 +30,7 @@ | Checkpoint 分支 | 可直接试用 | 无分支控件 | 可直接试用 | 从早期问题重问,或 `POST /api/v1/sessions/:id/branches`。 | | 证据引用 | 可直接试用 | 无选区 UI | 可直接试用 | 引用完整产出或选区,查看 `evidenceRefs` 诊断。 | | Data Link 图 | 需要配置 | 无图形视图 | 需要配置 | 配置兼容的 Data Link MCP Server,再打开「Data Link」。 | -| 用户身份 | 本地开发用户切换和 password auth 界面 | 使用后端身份 | 可直接试用 | `GET /api/v1/me`、`/api/v1/dev/*`、`/api/v1/auth/*`。 | +| 用户身份 | 密码登录 / 注册界面 | 使用后端会话身份 | 可直接试用 | `GET /api/v1/me`、`/api/v1/auth/*`。 | | 工作区文件 | 可上传、查看、下载、删除、复用 | 通过 run_config 使用已启用文件 | 受 capability 控制 | `files`、`GET/POST /api/v1/files`、`POST /api/v1/files/:id/promote`。 | | 对话附件 | 可直接试用 | 不提供附件上传命令 | 受 capability 控制 | `chat.fileUpload`,`POST /api/v1/chat/uploads`。 | | 图片输入 | 输入组件受开关控制 | 不提供图片输入命令 | 受 capability 控制 | `chat.imageInput`。 | @@ -115,7 +114,7 @@ TUI 适合远程服务器和终端工作流: - 客户端不能把数据库密码、模型 API Key、MCP Token 放进 Agent run body。 - 读接口不返回明文凭据。 - SQL 执行经过只读限制、行数限制、超时和审计。 -- 本地开发身份只用于试用和开发集成。 -- password auth 负责用户会话;生产部署仍需 Secret 管理、审计导出、访问控制策略和运维监控。 +- 所有产品路径都需要已认证的密码会话;不存在匿名或开发 token 身份。 +- 生产部署仍需 Secret 管理、审计导出、访问控制策略和运维监控。 继续阅读:[安全说明](security.md)。 diff --git a/docs/zh/guides/tui.md b/docs/zh/guides/tui.md index a3bf6269..994f61aa 100644 --- a/docs/zh/guides/tui.md +++ b/docs/zh/guides/tui.md @@ -4,11 +4,7 @@ ## 启动方式 -先启动完整开发服务或后端: - -```bash -npm run dev -``` +先按 [快速开始](../quick-start.md) 以正式态启动 API(`npm run start:api` 或 `./deploy.sh deploy`)。不要把 `npm run dev` 当作正式入口;贡献者热更新见快速开始附录。 启动 TUI: @@ -141,7 +137,7 @@ TUI 默认停留在 Chat。使用 `/outputs` 可以像 `/resume` 一样打开独 ## 排查 -- 无法连接后端:确认 `npm run dev` 或 `npm run dev:api` 正在运行。 +- 无法连接后端:确认正式态 API(`npm run start:api` 或一键部署)已在运行;贡献者热更新才用 `npm run dev:api`。 - 后端地址变更:用 `--runtime-url` 指定完整 `/api/copilotkit` 地址。 - 模型无响应:检查根目录 `.env` 中的 `LLM_PROVIDER`、`LLM_MODEL`、`LLM_BASE_URL` 和 `LLM_API_KEY`。 - 会话无法恢复:确认后端 `/api/v1/sessions` 接口可访问,并使用真实后端模式启动。 diff --git a/docs/zh/guides/web-workbench.md b/docs/zh/guides/web-workbench.md index a45a3e4b..bbb7a924 100644 --- a/docs/zh/guides/web-workbench.md +++ b/docs/zh/guides/web-workbench.md @@ -23,7 +23,6 @@ http://127.0.0.1:3000/login 正式态前端配置(`apps/web/.env.local`,改 `NEXT_PUBLIC_*` 后需重新 `build:web`): ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= API_PROXY_TARGET=http://127.0.0.1:8787 @@ -83,7 +82,7 @@ Web 工作台分三栏: | --- | --- | | 数据任务 | 新建、切换、重命名、置顶、删除任务。 | | 工作区资源 | 管理 Data Sources、Knowledge、Agent Tools 和 Assets。 | -| 用户菜单 | 查看当前用户、打开设置、退出登录或切换本地开发用户。 | +| 用户菜单 | 查看当前用户、打开设置或退出登录。 | 资源入口说明: diff --git a/docs/zh/quick-start.md b/docs/zh/quick-start.md index 39b01175..26daae26 100644 --- a/docs/zh/quick-start.md +++ b/docs/zh/quick-start.md @@ -135,9 +135,9 @@ LLM_API_KEY=你的_API_Key 根目录 `.env`: ```bash -DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000 +AUTH_REGISTRATION_MODE=open AUTH_EMAIL_DELIVERY=test AUTH_EMAIL_FROM=DataFoundry # smtp 相关可先留空 @@ -146,7 +146,6 @@ AUTH_EMAIL_FROM=DataFoundry `apps/web/.env.local`(会在 `next build` 时打进前端): ```bash -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password # 正式态留空,走同源 BFF(Cookie + CSRF) NEXT_PUBLIC_AGENT_RUNTIME_URL= NEXT_PUBLIC_CONFIG_API_URL= @@ -160,9 +159,9 @@ API_PROXY_TARGET=http://127.0.0.1:8787 在正式测试配置基础上改为: ```bash -DATAFOUNDRY_AUTH_MODE=password AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters AUTH_PUBLIC_BASE_URL=https://datafoundry.example.com +AUTH_REGISTRATION_MODE=closed AUTH_EMAIL_DELIVERY=smtp AUTH_EMAIL_FROM=DataFoundry AUTH_SMTP_HOST=smtp.example.com @@ -172,7 +171,7 @@ AUTH_SMTP_USER= AUTH_SMTP_PASSWORD= ``` -前端同样保持 `password` + 空公开 API URL + `API_PROXY_TARGET`。对外入口请用反代,样例见 [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example):静态资源压缩,SSE 路径 `/api/copilotkit` 关闭 gzip 与 `proxy_buffering`。 +前端保持空公开 API URL + `API_PROXY_TARGET`(同源 BFF + Cookie 会话)。对外入口请用反代,样例见 [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example):静态资源压缩,SSE 路径 `/api/copilotkit` 关闭 gzip 与 `proxy_buffering`。 ### 3. 构建并启动(正式测试 / 真实生产相同) @@ -306,6 +305,14 @@ curl http://127.0.0.1:8787/ready - **正式测试**(`AUTH_EMAIL_DELIVERY=test`):到运行 `start:api` 的终端里找验证链接。 - **真实生产**(`smtp`):检查 `AUTH_SMTP_*` 与发信账号;确认 `AUTH_PUBLIC_BASE_URL` 与对外域名一致。 +### 升级后出现 `METADATA_SCHEMA_INCOMPATIBLE` + +现象:API 启动失败,错误含 `METADATA_SCHEMA_INCOMPATIBLE`,并提到 `users.dev_token`。 + +原因:旧 Metadata 库仍带开发 token 列;password-only 切换不会做原地迁移。 + +处理:停栈后重置(或改指向空目录)`STORAGE_ROOT_DIR` / `METADATA_DB_PATH` / `MASTRA_STORAGE_PATH` / `FILE_ASSET_STORAGE_ROOT` / `WORKSPACE_ROOT`,再启动并重新注册。详见 [安全说明](security.md)。 + ### 模型不可用 现象:Agent run 报 provider、401、rate limit 或 model not found。 @@ -339,14 +346,13 @@ curl http://127.0.0.1:8787/ready 仅用于改代码时的热更新,**不是**正式测试或真实生产路径。与正式态二选一,不要混开。 -```bash -# 根目录 .env -DATAFOUNDRY_AUTH_MODE=dev - -# apps/web/.env.local -NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=dev -NEXT_PUBLIC_AGENT_RUNTIME_URL=http://127.0.0.1:8787/api/copilotkit +贡献者热更新仍使用密码会话;旧的开发 token / 认证模式开关已移除。 +根目录 `.env` 需具备 `AUTH_SESSION_SECRET` / `AUTH_PUBLIC_BASE_URL` / +`AUTH_REGISTRATION_MODE` / `AUTH_EMAIL_DELIVERY`(可用正式测试样例)。 +`apps/web/.env.local` 留空 `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL`, +并设置 `API_PROXY_TARGET=http://127.0.0.1:8787`。 +```bash npm run dev # 或:npm run dev:api && npm run dev:web ``` diff --git a/docs/zh/reference/configuration-api.md b/docs/zh/reference/configuration-api.md index 45145a1f..fc9d8e98 100644 --- a/docs/zh/reference/configuration-api.md +++ b/docs/zh/reference/configuration-api.md @@ -33,24 +33,16 @@ effectiveRunConfig = merge(workspaceDefaults, perRunOverrides, serverPolicy) 后端合并后生成不可变快照,再交给 Agent Runtime。 -## 本地开发鉴权 +## 鉴权 -```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -不传请求头时,后端使用开发默认身份和默认 workspace。Web v1 按「一个用户拥有 default workspace」处理,不暴露 workspace 切换。 - -配置 API 和 AG-UI run 必须使用同一组身份头: +配置 API 与 AG-UI run 必须使用同一密码会话(`df_session` Cookie)。非安全方法还需要 `X-CSRF-Token`(来自 `df_csrf` Cookie): ```text -REST /api/v1/* -> Authorization / X-Dev-Token / X-Workspace-Id -CopilotKit /api/copilotkit -> Authorization / X-Dev-Token / X-Workspace-Id +REST /api/v1/* -> Cookie session + X-CSRF-Token (unsafe methods) +CopilotKit /api/copilotkit -> Cookie session + X-CSRF-Token (unsafe methods) ``` -这样工作区默认资源、服务端会话、文件资产、产出、SQL audit 和 run history 会留在同一个用户作用域。密码认证模式下,Cookie 负责识别用户,非安全方法还需要发送 `X-CSRF-Token`。 +这样工作区默认资源、服务端会话、文件资产、产出、SQL audit 和 run history 会留在同一个用户作用域。Web v1 不暴露 workspace 切换。 ## 通用资源字段 diff --git a/docs/zh/reference/rest-api.md b/docs/zh/reference/rest-api.md index 69c2126b..bb74939e 100644 --- a/docs/zh/reference/rest-api.md +++ b/docs/zh/reference/rest-api.md @@ -35,41 +35,26 @@ http://127.0.0.1:8787 ## 身份与鉴权 -本地开发支持这些请求头: +仅支持密码会话 Cookie 与 CSRF。业务请求需携带登录后的 `df_session`;非安全方法还需 `X-CSRF-Token`(来自 `df_csrf` Cookie): ```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -不传请求头时,后端使用开发默认身份和默认 workspace。Web v1 不暴露 workspace 切换;除非你在自建集成里管理 workspace 路由,否则使用 `default`。 - -为了保证本地用户隔离,`/api/v1/*` REST 请求和 `POST /api/copilotkit` 必须发送同一组身份头。如果两条通道使用不同身份,session、资源、文件、产出和 run events 会进入不同用户作用域。 - -密码模式使用会话 Cookie 和 CSRF: - -```text -DATAFOUNDRY_AUTH_MODE=password X-CSRF-Token: ``` -本地开发 token 模式使用 `DATAFOUNDRY_AUTH_MODE=dev`(仅贡献者热更新)。正式测试与真实生产默认使用 `password`,除非显式覆盖。 +Web v1 不暴露 workspace 切换;自建集成除非自行管理 workspace 路由,否则使用登录会话绑定的 workspace。`/api/v1/*` 与 `POST /api/copilotkit` 必须使用同一会话,否则 session、资源、文件、产出和 run events 会落到不同用户作用域。 ## 身份接口 | Method | Path | 用途 | | --- | --- | --- | | GET | `/api/v1/me` | 读取当前用户和 workspace。 | -| GET | `/api/v1/dev/identities` | 列出本地开发用户。生产默认禁用。 | -| POST | `/api/v1/dev/users` | 创建或更新本地开发用户。生产默认禁用。 | ## 密码认证接口 -这些接口在 password auth 模式下启用: | Method | Path | 用途 | | --- | --- | --- | +| GET | `/api/v1/auth/status` | 读取公开认证状态(含 `registrationEnabled`,不含密钥)。 | | POST | `/api/v1/auth/register` | 创建用户账号和验证 token。 | | POST | `/api/v1/auth/login` | 登录并设置 `df_session` 和 `df_csrf` Cookie。 | | POST | `/api/v1/auth/verify-email` | 验证邮箱 token。 | diff --git a/docs/zh/security.md b/docs/zh/security.md index 6e6ab71e..4cb65ea5 100644 --- a/docs/zh/security.md +++ b/docs/zh/security.md @@ -9,7 +9,6 @@ ```text replace-with-your-key 你的_API_Key - ``` 不要把真实模型 Key、数据库密码、MCP Token、私钥、Cookie、个人访问令牌或公司内网地址写进 README、docs、issue 示例或截图。 @@ -59,31 +58,11 @@ replace-with-your-key - 对敏感库表使用 allowlist。 - SQLite、CSV、Excel、DuckDB 文件路径必须是后端进程可访问的路径。 -## 本地开发边界 +## 身份与会话 -本地开发接口支持开发 token 和默认 workspace: +DataFoundry 仅支持基于 Cookie 的密码会话。开发 token、`/api/v1/dev/*` 与 `DATAFOUNDRY_AUTH_MODE` 已移除。 -```text -Authorization: Bearer -X-Dev-Token: -X-Workspace-Id: default -``` - -不传请求头时,后端使用开发默认身份和默认 workspace。Web v1 按「一个用户拥有 default workspace」处理,不暴露 workspace 切换。自建客户端时,REST `/api/v1/*` 和 CopilotKit `/api/copilotkit` 必须发送同一组身份头,避免配置、会话、文件、产出和 run history 落到不同用户作用域。 - -开发期身份接口: - -| Method | Path | 用途 | -| --- | --- | --- | -| GET | `/api/v1/me` | 读取当前用户和 workspace。 | -| GET | `/api/v1/dev/identities` | 列出本地开发用户。 | -| POST | `/api/v1/dev/users` | 创建或更新本地开发用户。 | - -`/api/v1/dev/*` 生产默认禁用,除非显式设置 `DATAFOUNDRY_ENABLE_DEV_IDENTITY_API=true`。 - -## 密码认证模式 - -设置 `DATAFOUNDRY_AUTH_MODE=password` 后,后端使用基于 Cookie 的密码认证。正式态默认使用 `password`。必要配置: +必要配置: ```text AUTH_SESSION_SECRET=replace-with-at-least-32-random-characters @@ -93,7 +72,7 @@ AUTH_EMAIL_DELIVERY=test AUTH_EMAIL_FROM=DataFoundry ``` -`AUTH_REGISTRATION_MODE` 在 password 模式下必填(`open` = 开放自助注册,`closed` = 注册返回 `REGISTRATION_CLOSED`)。一键部署默认 `open`,便于正式本地/测试;对公网暴露且不接受公开注册时请设为 `closed`。`GET /api/v1/auth/status` 仅暴露 `registrationEnabled`,不含密钥。 +`AUTH_REGISTRATION_MODE` 必填(`open` = 开放自助注册,`closed` = 注册返回 `REGISTRATION_CLOSED`)。一键部署默认 `open`;对公网暴露且不接受公开注册时请设为 `closed`。`GET /api/v1/me` 读取当前用户;`GET /api/v1/auth/status` 仅暴露 `registrationEnabled`,不含密钥。 正式态分两种环境(启动命令相同): @@ -102,9 +81,13 @@ AUTH_EMAIL_FROM=DataFoundry | 正式测试 | `test`(验证链接打 API 控制台) | 回环 HTTP URL | `open` | | 真实生产 | `smtp`(并配置 `AUTH_SMTP_*`) | 公网 HTTPS 域名 | 默认 `closed`,除非明确要开放自助注册 | -密码模式提供 `/api/v1/auth/*` 接口,用于注册、登录、邮箱验证、密码重置、退出登录、会话列表和修改密码。非安全方法请求需要携带来自 `df_csrf` Cookie 的 `X-CSRF-Token`。会话 Cookie 名为 `df_session`。Cookie 的 `Path` / `Secure` 跟随 `AUTH_PUBLIC_BASE_URL`(HTTPS ⇒ `Secure`;pathname 前缀成为 cookie path)。非回环 `AUTH_PUBLIC_BASE_URL` 时禁止 `AUTH_EMAIL_DELIVERY=test`。 +`/api/v1/auth/*` 提供注册、登录、邮箱验证、密码重置、退出登录、会话列表和修改密码。非安全方法需要 `X-CSRF-Token`(来自 `df_csrf` Cookie)。会话 Cookie 为 `df_session`。Cookie 的 `Path` / `Secure` 跟随 `AUTH_PUBLIC_BASE_URL`(HTTPS ⇒ `Secure`;pathname 前缀成为 cookie path)。非回环 `AUTH_PUBLIC_BASE_URL` 时禁止 `AUTH_EMAIL_DELIVERY=test`。 + +前端请留空 `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL`,让浏览器走同源 Next BFF;上游 API 用 `API_PROXY_TARGET`(写在 `apps/web/.env.local`)。启动命令:`npm run build && npm run build:web && npm run start:api && npm run start:web`。真实生产反代样例见 `deploy/nginx.datafoundry.conf.example`。 + +若旧 `.env` 仍含 `DATAFOUNDRY_AUTH_MODE=password`,API 会忽略该值;`./deploy.sh deploy` 会把它从配置中剥离。`DATAFOUNDRY_AUTH_MODE=dev` 会直接导致启动失败。 -前端请同步设置 `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password`,并留空 `NEXT_PUBLIC_AGENT_RUNTIME_URL` / `NEXT_PUBLIC_CONFIG_API_URL`,让浏览器走同源 Next BFF;上游 API 用 `API_PROXY_TARGET`(写在 `apps/web/.env.local`)。启动命令:`npm run build && npm run build:web && npm run start:api && npm run start:web`。真实生产反代样例见 `deploy/nginx.datafoundry.conf.example`。 +password-only 切换**不会**迁移仍含 `users.dev_token` 的 Metadata 库。打开此类数据库会以 `METADATA_SCHEMA_INCOMPATIBLE` 失败。请停栈后重置 `STORAGE_ROOT_DIR` / `METADATA_DB_PATH` / `MASTRA_STORAGE_PATH` / `FILE_ASSET_STORAGE_ROOT` / `WORKSPACE_ROOT`(或改指向空目录),再启动并重新注册。本次切换不提供原地 schema 升级。 真实生产部署还需要 Secret 管理、审计导出、访问控制和运维监控。 diff --git a/package.json b/package.json index 36cc030b..1e7a26fd 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,8 @@ "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", + "test:auth-foundation": "npm run build && node --test scripts/lib/authenticated-test-client.test.mjs scripts/lib/metadata-test-identity.test.mjs scripts/auth-foundation.test.mjs", + "test:password-only": "npm run build && node --test scripts/password-only-cutover.test.mjs", "smoke:auth": "npm run build && node scripts/smoke-auth.mjs", "smoke:tui-auth-sharing": "npm run build && npm --workspace @datafoundry/tui run build && node scripts/smoke-tui-auth-sharing.mjs", "smoke:files": "npm run build && node scripts/smoke-files.mjs", diff --git a/packages/artifacts/src/session-output-service.test.ts b/packages/artifacts/src/session-output-service.test.ts index 845e0824..ab01b08c 100644 --- a/packages/artifacts/src/session-output-service.test.ts +++ b/packages/artifacts/src/session-output-service.test.ts @@ -1,5 +1,5 @@ import { LocalFileAssetService } from "@datafoundry/files"; -import { createMetadataStore } from "@datafoundry/metadata"; +import { createMetadataStore, createVerifiedTestIdentity } from "@datafoundry/metadata"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -13,34 +13,40 @@ const createTestServices = () => { const root = mkdtempSync(join(tmpdir(), "session-output-service-")); roots.push(root); const metadataStore = createMetadataStore({ - database_path: join(root, "metadata.sqlite"), - dev_user: { - id: "user-1", - email: "user@example.com", - display_name: "Test User", - dev_token: "dev-token" - } + database_path: join(root, "metadata.sqlite") }); - metadataStore.workspaces.createPersonal({ - id: "workspace-1", - owner_user_id: "user-1", - name: "Workspace" + const { userId, workspaceId } = createVerifiedTestIdentity(metadataStore, { + email: "user@example.com", + displayName: "Test User", + workspaceName: "Workspace" }); + // Keep stable ids expected by assertions below. + const sessionId = "session-1"; + const runId = "run-1"; metadataStore.sessions.create({ - user_id: "user-1", - id: "session-1" + user_id: userId, + id: sessionId }); metadataStore.runs.create({ - user_id: "user-1", - session_id: "session-1", - id: "run-1", + user_id: userId, + session_id: sessionId, + id: runId, user_input: "test" }); const fileAssetService = new LocalFileAssetService(metadataStore, { storageRoot: join(root, "files") }); const sessionOutputService = new SessionOutputService(metadataStore, fileAssetService); - return { fileAssetService, metadataStore, root, sessionOutputService }; + return { + fileAssetService, + metadataStore, + root, + sessionOutputService, + userId, + workspaceId, + sessionId, + runId + }; }; afterEach(() => { @@ -51,30 +57,31 @@ afterEach(() => { describe("SessionOutputService", () => { it("returns null for paths excluded from session outputs", async () => { - const { root, sessionOutputService } = createTestServices(); + const { root, sessionOutputService, userId, workspaceId, sessionId, runId } = createTestServices(); const sourcePath = join(root, "analysis.py"); writeFileSync(sourcePath, "print('draft')\n"); await expect(sessionOutputService.upsertFromSessionFile({ - user_id: "user-1", - workspace_id: "workspace-1", - session_id: "session-1", - run_id: "run-1", + user_id: userId, + workspace_id: workspaceId, + session_id: sessionId, + run_id: runId, path: "analysis.py", source_path: sourcePath })).resolves.toBeNull(); }); it("upserts one output per session file path and appends versions", async () => { - const { metadataStore, root, sessionOutputService } = createTestServices(); + const { metadataStore, root, sessionOutputService, userId, workspaceId, sessionId, runId } = + createTestServices(); const sourcePath = join(root, "summary.md"); writeFileSync(sourcePath, "# First\n"); const first = await sessionOutputService.upsertFromSessionFile({ - user_id: "user-1", - workspace_id: "workspace-1", - session_id: "session-1", - run_id: "run-1", + user_id: userId, + workspace_id: workspaceId, + session_id: sessionId, + run_id: runId, path: "reports/summary.md", source_path: sourcePath, tool_call_id: "tool-1" @@ -82,10 +89,10 @@ describe("SessionOutputService", () => { writeFileSync(sourcePath, "# Second\n"); const second = await sessionOutputService.upsertFromSessionFile({ - user_id: "user-1", - workspace_id: "workspace-1", - session_id: "session-1", - run_id: "run-1", + user_id: userId, + workspace_id: workspaceId, + session_id: sessionId, + run_id: runId, path: "reports/summary.md", source_path: sourcePath, tool_call_id: "tool-2" @@ -96,17 +103,17 @@ describe("SessionOutputService", () => { expect(second?.artifact.id).toBe(first?.artifact.id); expect(second?.artifact.file_asset_ref_id).not.toBe(first?.artifact.file_asset_ref_id); expect(metadataStore.artifacts.listBySession({ - user_id: "user-1", - session_id: "session-1" + user_id: userId, + session_id: sessionId })).toHaveLength(1); expect(metadataStore.artifacts.findBySessionLogicalKey({ - user_id: "user-1", - session_id: "session-1", + user_id: userId, + session_id: sessionId, logical_key: "session_file:reports/summary.md" })?.id).toBe(first?.artifact.id); const versions = metadataStore.artifactVersions.listByArtifact({ - user_id: "user-1", + user_id: userId, artifact_id: first?.artifact.id ?? "" }); expect(versions.map((version) => version.version)).toEqual([1, 2]); diff --git a/packages/knowledge/src/knowledge-document-lifecycle.test.ts b/packages/knowledge/src/knowledge-document-lifecycle.test.ts index 507f9139..a710c931 100644 --- a/packages/knowledge/src/knowledge-document-lifecycle.test.ts +++ b/packages/knowledge/src/knowledge-document-lifecycle.test.ts @@ -1,4 +1,4 @@ -import { createMetadataStore } from "@datafoundry/metadata"; +import { createMetadataStore, createVerifiedTestIdentity } from "@datafoundry/metadata"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -20,18 +20,13 @@ const createHarness = (embeddingService?: EmbeddingService) => { const root = mkdtempSync(join(tmpdir(), "knowledge-lifecycle-")); roots.push(root); const metadataStore = createMetadataStore({ - database_path: join(root, "metadata.sqlite"), - dev_user: { - id: "user-1", - email: "user@example.com", - display_name: "Test User", - dev_token: "dev-token" - } + database_path: join(root, "metadata.sqlite") }); + const { userId, workspaceId } = createVerifiedTestIdentity(metadataStore); metadataStore.configResources.upsert({ id: "kb-1", - workspace_id: "default", - user_id: "user-1", + workspace_id: workspaceId, + user_id: userId, kind: "knowledge-base", name: "KB", payload: { @@ -47,7 +42,7 @@ const createHarness = (embeddingService?: EmbeddingService) => { ? { embedding: embeddingConfig, embeddingService } : {}) }); - return { knowledge, metadataStore, root }; + return { knowledge, metadataStore, root, userId }; }; afterEach(() => { @@ -61,28 +56,28 @@ describe("LocalKnowledgeService document lifecycle", () => { const embed: EmbeddingService = { embed: async (texts) => texts.map((_, index) => [index + 1, 0, 0]) }; - const { knowledge, metadataStore } = createHarness(embed); + const { knowledge, metadataStore, userId } = createHarness(embed); const doc = await knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: "keep-me.md", content: "alpha revenue metric one" }); const other = await knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: "other.md", content: "beta cost metric two" }); const deleted = knowledge.deleteDocument({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", document_id: doc.id }); expect(deleted).toEqual({ deleted: true, id: doc.id }); - const remaining = knowledge.listDocuments({ user_id: "user-1", collection_id: "kb-1" }); + const remaining = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" }); expect(remaining.map((item) => item.id)).toEqual([other.id]); const chunkCount = metadataStore.db.prepare( @@ -96,7 +91,7 @@ describe("LocalKnowledgeService document lifecycle", () => { expect(embeddingCount.count).toBe(0); const hits = await knowledge.retrieve({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", query: "alpha revenue metric" }); @@ -113,21 +108,21 @@ describe("LocalKnowledgeService document lifecycle", () => { return texts.map((_, index) => [index + 1, 0, 0]); } }; - const { knowledge, metadataStore } = createHarness(embed); + const { knowledge, metadataStore, userId } = createHarness(embed); await expect(knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: "failed.md", content: "alpha revenue metric recoverable" })).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/); - const failed = knowledge.listDocuments({ user_id: "user-1", collection_id: "kb-1" })[0]; + const failed = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]; expect(failed?.status).toBe("failed"); shouldFail = false; const recovered = await knowledge.reindexDocument({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", document_id: failed!.id }); @@ -149,18 +144,18 @@ describe("LocalKnowledgeService document lifecycle", () => { return texts.map((_, index) => [index + 1, 0, 0]); } }; - const { knowledge } = createHarness(embed); + const { knowledge, userId } = createHarness(embed); await expect(knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: "stuck.md", content: "alpha revenue metric stuck" })).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/); - expect(knowledge.listDocuments({ user_id: "user-1", collection_id: "kb-1" })[0]?.status).toBe("failed"); + expect(knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status).toBe("failed"); shouldFail = false; - await knowledge.reindex({ user_id: "user-1", collection_id: "kb-1" }); - expect(knowledge.listDocuments({ user_id: "user-1", collection_id: "kb-1" })[0]?.status).toBe("ready"); + await knowledge.reindex({ user_id: userId, collection_id: "kb-1" }); + expect(knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status).toBe("ready"); }); it("clears partial vectors and marks documents failed when collection reindex aborts mid-batch", async () => { @@ -175,10 +170,10 @@ describe("LocalKnowledgeService document lifecycle", () => { return texts.map((_, index) => [index + 1, 0, 0]); } }; - const { knowledge, metadataStore } = createHarness(embed); + const { knowledge, metadataStore, userId } = createHarness(embed); for (let index = 0; index < 33; index += 1) { const doc = await knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: `doc-${index}.md`, content: `alpha revenue metric document ${index} unique content here` @@ -189,17 +184,17 @@ describe("LocalKnowledgeService document lifecycle", () => { batches = 0; failAfterBatches = 1; await expect(knowledge.reindex({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1" })).rejects.toThrow(/EMBEDDING_REQUEST_FAILED:500:mid-reindex/); - const documents = knowledge.listDocuments({ user_id: "user-1", collection_id: "kb-1" }); + const documents = knowledge.listDocuments({ user_id: userId, collection_id: "kb-1" }); expect(documents).toHaveLength(33); expect(documents.every((item) => item.status === "failed")).toBe(true); const embeddingCount = metadataStore.db.prepare( "SELECT COUNT(*) AS count FROM knowledge_embeddings WHERE user_id = ? AND collection_id = ?" - ).get("user-1", "kb-1") as { count: number }; + ).get(userId, "kb-1") as { count: number }; expect(embeddingCount.count).toBe(0); }); @@ -210,9 +205,9 @@ describe("LocalKnowledgeService document lifecycle", () => { } }; // FTS-only ingest (no embedding key) leaves a ready document with chunks. - const { knowledge, metadataStore } = createHarness(); + const { knowledge, metadataStore, userId } = createHarness(); const doc = await knowledge.ingestText({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", filename: "fts-only.md", content: "alpha revenue metric fts" @@ -225,12 +220,12 @@ describe("LocalKnowledgeService document lifecycle", () => { }); await expect(knowledgeWithEmbed.reindexDocument({ - user_id: "user-1", + user_id: userId, collection_id: "kb-1", document_id: doc.id })).rejects.toThrow(/EMBEDDING_REQUEST_FAILED/); - expect(knowledgeWithEmbed.listDocuments({ user_id: "user-1", collection_id: "kb-1" })[0]?.status) + expect(knowledgeWithEmbed.listDocuments({ user_id: userId, collection_id: "kb-1" })[0]?.status) .toBe("failed"); }); }); diff --git a/packages/metadata/src/conversation-message-repository.test.ts b/packages/metadata/src/conversation-message-repository.test.ts index ce9197d4..e6a9a2fb 100644 --- a/packages/metadata/src/conversation-message-repository.test.ts +++ b/packages/metadata/src/conversation-message-repository.test.ts @@ -3,17 +3,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createMetadataStore } from "./index.js"; +import { createMetadataStore, createVerifiedTestIdentity } from "./index.js"; describe("ConversationMessageRepository", () => { it("finds only the latest persisted assistant message for the requested run", () => { const root = mkdtempSync(join(tmpdir(), "conversation-message-")); try { const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Conversation" }); + const { userId } = createVerifiedTestIdentity(metadata); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Conversation" }); for (const runId of ["run-1", "run-2"]) { metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: runId, session_id: "session-1", user_input: "test", @@ -23,7 +24,7 @@ describe("ConversationMessageRepository", () => { const append = (runId: string, role: "assistant" | "user", messageId: string): void => { metadata.conversationMessages.append({ id: `${runId}:${messageId}`, - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: runId, role, @@ -38,7 +39,7 @@ describe("ConversationMessageRepository", () => { append("run-1", "assistant", "assistant-2"); expect(metadata.conversationMessages.findLatestAssistantByRun({ - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: "run-1" })?.message_id).toBe("assistant-2"); diff --git a/packages/metadata/src/index.ts b/packages/metadata/src/index.ts index 397cd733..25897284 100644 --- a/packages/metadata/src/index.ts +++ b/packages/metadata/src/index.ts @@ -1,6 +1,6 @@ import { EventType, type BaseEvent } from "@ag-ui/core"; import type { ArtifactSummary, ArtifactType, RunEventEnvelope } from "@datafoundry/contracts"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import { mkdirSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; @@ -18,7 +18,6 @@ export type UserRecord = { id: string; email?: string; display_name?: string; - dev_token?: string; email_verified_at?: string; disabled_at?: string; password_updated_at?: string; @@ -392,12 +391,6 @@ export type SqlAuditLogRecord = { export type MetadataStoreOptions = { database_path?: string; secret_master_key?: string; - dev_user?: { - id: string; - email: string; - display_name: string; - dev_token: string; - }; }; export type CreateSessionInput = { @@ -628,13 +621,6 @@ export type CreateQueryHistoryInput = { run_id?: string; }; -const DEFAULT_DEV_USER = { - id: "dev-user", - email: "dev@example.com", - display_name: "Dev User", - dev_token: "dev-token" -}; - export class MetadataStore { readonly authAuditEvents: AuthAuditEventRepository; readonly authSessions: AuthSessionRepository; @@ -852,30 +838,6 @@ export class UserRepository { return this.getById({ user_id: input.id }); } - upsertDevUser(input: { id: string; email: string; display_name: string; dev_token: string }): UserRecord { - const now = new Date().toISOString(); - - this.db - .prepare( - ` - INSERT INTO users (id, email, display_name, dev_token, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET - email = excluded.email, - display_name = excluded.display_name, - dev_token = excluded.dev_token, - updated_at = excluded.updated_at - ` - ) - .run(input.id, input.email, input.display_name, input.dev_token, now, now); - - return this.getById({ user_id: input.id }); - } - - getByDevToken(input: { dev_token: string }): Optional { - return mapUserRow(this.db.prepare("SELECT * FROM users WHERE dev_token = ?").get(input.dev_token)); - } - findByEmail(input: { email: string }): Optional { return mapUserRow(this.db.prepare("SELECT * FROM users WHERE lower(email) = lower(?)").get(input.email)); } @@ -3322,11 +3284,49 @@ export const createMetadataStore = (options: MetadataStoreOptions = {}): Metadat db.exec("PRAGMA journal_mode = WAL"); db.exec("PRAGMA foreign_keys = ON"); runMigrations(db); + assertPasswordOnlyUsersSchema(db); - const store = new MetadataStore(db, options.secret_master_key); - store.users.upsertDevUser(options.dev_user ?? DEFAULT_DEV_USER); + return new MetadataStore(db, options.secret_master_key); +}; + +/** + * Low-level fixture for package/smoke tests that talk to MetadataStore directly. + * Does not create password credentials or HTTP sessions. + */ +export function createVerifiedTestIdentity( + metadata: MetadataStore, + options: { email?: string; displayName?: string; workspaceName?: string } = {} +): { userId: string; workspaceId: string; email: string } { + const userId = randomUUID(); + const workspaceId = `personal-${userId}`; + const email = options.email ?? `${userId}@example.test`; + const user = metadata.users.createPasswordUser({ + id: userId, + email, + display_name: options.displayName ?? "Test User" + }); + metadata.users.markEmailVerified({ user_id: user.id }); + const workspace = metadata.workspaces.createPersonal({ + id: workspaceId, + owner_user_id: user.id, + name: options.workspaceName ?? "Test Workspace" + }); + metadata.workspaceMemberships.upsertOwner({ + workspace_id: workspace.id, + user_id: user.id + }); + return { userId: user.id, workspaceId: workspace.id, email: user.email ?? email }; +} - return store; +const assertPasswordOnlyUsersSchema = (db: DatabaseSync): void => { + const columns = db + .prepare("PRAGMA table_info(users)") + .all() as Array<{ name: string }>; + if (columns.some((column) => column.name === "dev_token")) { + throw new Error( + "METADATA_SCHEMA_INCOMPATIBLE: users.dev_token is present. Password-only cutover does not migrate old Metadata DBs; stop the stack and manually reset STORAGE_ROOT_DIR / METADATA_DB_PATH / MASTRA_STORAGE_PATH / FILE_ASSET_STORAGE_ROOT / WORKSPACE_ROOT, then restart and re-register." + ); + } }; export const runEventRecordToEnvelope = (record: RunEventRecord): RunEventEnvelope => ({ @@ -3386,7 +3386,6 @@ const runMigrations = (db: DatabaseSync): void => { id TEXT PRIMARY KEY, email TEXT UNIQUE, display_name TEXT, - dev_token TEXT UNIQUE, email_verified_at TEXT, disabled_at TEXT, password_updated_at TEXT, @@ -4476,7 +4475,6 @@ const mapUserRow = (row: unknown): Optional => { const email = optionalString(row.email); const displayName = optionalString(row.display_name); - const devToken = optionalString(row.dev_token); const emailVerifiedAt = optionalString(row.email_verified_at); const disabledAt = optionalString(row.disabled_at); const passwordUpdatedAt = optionalString(row.password_updated_at); @@ -4485,7 +4483,6 @@ const mapUserRow = (row: unknown): Optional => { id: requiredString(row, "id"), ...(email ? { email } : {}), ...(displayName ? { display_name: displayName } : {}), - ...(devToken ? { dev_token: devToken } : {}), ...(emailVerifiedAt ? { email_verified_at: emailVerifiedAt } : {}), ...(disabledAt ? { disabled_at: disabledAt } : {}), ...(passwordUpdatedAt ? { password_updated_at: passwordUpdatedAt } : {}), diff --git a/packages/metadata/src/run-event-dedupe.test.ts b/packages/metadata/src/run-event-dedupe.test.ts index e856e835..b018eed7 100644 --- a/packages/metadata/src/run-event-dedupe.test.ts +++ b/packages/metadata/src/run-event-dedupe.test.ts @@ -4,16 +4,17 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createMetadataStore, RunEventWriter } from "./index.js"; +import { createMetadataStore, createVerifiedTestIdentity, RunEventWriter } from "./index.js"; describe("RunEventWriter protocol event deduplication", () => { it("does not append a replayed protocol journal event twice", () => { const root = mkdtempSync(join(tmpdir(), "protocol-event-dedupe-")); try { const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + const { userId } = createVerifiedTestIdentity(metadata); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Protocol" }); metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-1", session_id: "session-1", user_input: "test", @@ -26,11 +27,11 @@ describe("RunEventWriter protocol event deduplication", () => { value: { eventId: "protocol-event-1" } }; - const first = writer.write({ user_id: "dev-user", run_id: "run-1", session_id: "session-1", event }); - const replay = writer.write({ user_id: "dev-user", run_id: "run-1", session_id: "session-1", event }); + const first = writer.write({ user_id: userId, run_id: "run-1", session_id: "session-1", event }); + const replay = writer.write({ user_id: userId, run_id: "run-1", session_id: "session-1", event }); expect(replay.seq).toBe(first.seq); - expect(writer.replay({ user_id: "dev-user", run_id: "run-1" })).toHaveLength(1); + expect(writer.replay({ user_id: userId, run_id: "run-1" })).toHaveLength(1); metadata.close(); } finally { rmSync(root, { recursive: true, force: true }); diff --git a/packages/metadata/src/session-delete.test.ts b/packages/metadata/src/session-delete.test.ts index 8159ba9a..79d92dd3 100644 --- a/packages/metadata/src/session-delete.test.ts +++ b/packages/metadata/src/session-delete.test.ts @@ -3,23 +3,24 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { createMetadataStore } from "./index.js"; +import { createMetadataStore, createVerifiedTestIdentity } from "./index.js"; describe("SessionRepository.delete", () => { it("deletes persisted protocol state and journal events with the session", () => { const root = mkdtempSync(join(tmpdir(), "session-delete-protocol-")); const metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + const { userId } = createVerifiedTestIdentity(metadata); try { - metadata.sessions.create({ user_id: "dev-user", id: "session-1", title: "Protocol" }); + metadata.sessions.create({ user_id: userId, id: "session-1", title: "Protocol" }); metadata.runs.create({ - user_id: "dev-user", + user_id: userId, id: "run-1", session_id: "session-1", user_input: "test", status: "completed" }); metadata.contextPackageSnapshots.create({ - user_id: "dev-user", + user_id: userId, session_id: "session-1", run_id: "run-1", package_id: "context-1", @@ -27,7 +28,7 @@ describe("SessionRepository.delete", () => { payload: {} }); metadata.protocolStates.compareAndSetWithEvents({ - user_id: "dev-user", + user_id: userId, run_id: "run-1", segment_id: "segment-1", expected_revision: -1, @@ -52,17 +53,17 @@ describe("SessionRepository.delete", () => { revision: 0 }]); - expect(metadata.sessions.delete({ user_id: "dev-user", session_id: "session-1" })).toEqual({ + expect(metadata.sessions.delete({ user_id: userId, session_id: "session-1" })).toEqual({ deleted: true, deletedSessionIds: ["session-1"] }); - expect(metadata.sessions.list({ user_id: "dev-user" })).toHaveLength(0); + expect(metadata.sessions.list({ user_id: userId })).toHaveLength(0); expect(metadata.protocolStates.find({ - user_id: "dev-user", + user_id: userId, run_id: "run-1", segment_id: "segment-1" })).toBeUndefined(); - expect(metadata.protocolStates.pendingEvents({ user_id: "dev-user", run_id: "run-1" })).toEqual([]); + expect(metadata.protocolStates.pendingEvents({ user_id: userId, run_id: "run-1" })).toEqual([]); } finally { metadata.close(); rmSync(root, { recursive: true, force: true }); diff --git a/scripts/auth-foundation.test.mjs b/scripts/auth-foundation.test.mjs index e39e3c49..a679314b 100644 --- a/scripts/auth-foundation.test.mjs +++ b/scripts/auth-foundation.test.mjs @@ -53,7 +53,38 @@ const PUBLIC_HTTP_TARGETS = [ } }); -const DIRECT_METADATA_FIXTURE_TARGETS = []; +const DIRECT_METADATA_FIXTURE_TARGETS = [ + "diagnose-tool-result-events.mjs", + "lib/metadata-test-identity.mjs", + "lib/metadata-test-identity.test.mjs", + "password-only-cutover.test.mjs", + "run-test-every-tool-prompt.mjs", + "smoke-agent-runtime.mjs", + "smoke-collaboration-tools.mjs", + "smoke-conversation-memory.mjs", + "smoke-copilotkit-context.mjs", + "smoke-data-gateway.mjs", + "smoke-files.mjs", + "smoke-knowledge-retrieval-policy.mjs", + "smoke-long-term-memory.mjs", + "smoke-memory-recall-shadow.mjs", + "smoke-metadata.mjs", + "smoke-protocol-recovery.mjs", + "smoke-run-config-disabled.mjs", + "smoke-run-config-mcp-degraded.mjs", + "smoke-run-finalizer.mjs", + "smoke-run-identity.mjs", + "smoke-skills.mjs", + "smoke-sql-readonly.mjs", + "smoke-task-state.mjs", + "smoke-tool-state-isolation.mjs", + "smoke-trace-sections.mjs", + "test-builtin-dtc-growth-datasource.mjs", + "test-kb-skill-whitelist-fixes.mjs", + "verify-tools/data-tools.mjs", + "verify-tools/knowledge-tool.mjs", + "verify-tools/task-collab-tools.mjs" +]; const AUTH_FOUNDATION_HARNESS_TARGETS = [ "auth-foundation.test.mjs", @@ -61,8 +92,8 @@ const AUTH_FOUNDATION_HARNESS_TARGETS = [ ]; const FORBIDDEN_DEV_AUTH_PATTERNS = [ - /X-Dev-Token/i, - /\bdev-token\b/, + new RegExp(["X", "Dev", "Token"].join("-"), "i"), + new RegExp(String.raw`\b` + ["dev", "token"].join("-") + String.raw`\b`), /Authorization\s*:\s*["']?Bearer\s+dev\b/i ]; @@ -89,7 +120,6 @@ 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", @@ -141,8 +171,8 @@ test("validateAuthPublicUrl rejects illegal URL shapes", () => { test("deploy ensureDeploymentEnvironment defaults are accepted by password auth config", () => { const secret = "deploy-default-session-secret-32b!!"; const fresh = ensureDeploymentEnvironment("", { randomSecret: () => secret }); + assert.equal(fresh.env.DATAFOUNDRY_AUTH_MODE, undefined); const freshConfig = loadPasswordAuthConfig(fresh.env); - assert.equal(freshConfig.mode, "password"); assert.equal(freshConfig.registrationMode, "open"); assert.equal(freshConfig.emailDelivery, "test"); @@ -159,6 +189,47 @@ test("deploy ensureDeploymentEnvironment defaults are accepted by password auth assert.equal(upgradedConfig.registrationMode, "open"); }); +test("ignores legacy DATAFOUNDRY_AUTH_MODE=password and rejects removed modes", () => { + assert.doesNotThrow(() => loadPasswordAuthConfig(baseEnv({ DATAFOUNDRY_AUTH_MODE: "password" }))); + assert.doesNotThrow(() => loadPasswordAuthConfig(baseEnv({ DATAFOUNDRY_AUTH_MODE: "" }))); + assert.throws( + () => loadPasswordAuthConfig(baseEnv({ DATAFOUNDRY_AUTH_MODE: "dev" })), + /DATAFOUNDRY_AUTH_MODE/ + ); +}); + +test("unauthenticated business routes and development tokens are rejected", async () => { + await withPasswordApi({}, async ({ baseUrl }) => { + const me = await fetch(`${baseUrl}/api/v1/me`); + assert.equal(me.status, 401); + + const legacyTokenHeader = ["X", "Dev", "Token"].join("-"); + const legacyTokenValue = ["dev", "token"].join("-"); + const withDevToken = await fetch(`${baseUrl}/api/v1/me`, { + headers: { + [legacyTokenHeader]: legacyTokenValue, + Authorization: `Bearer ${legacyTokenValue}` + } + }); + assert.equal(withDevToken.status, 401); + + const withWorkspace = await fetch(`${baseUrl}/api/v1/me`, { + headers: { "X-Workspace-Id": "default" } + }); + assert.equal(withWorkspace.status, 401); + + const health = await fetch(`${baseUrl}/healthz`); + assert.equal(health.status, 200); + + const status = await fetch(`${baseUrl}/api/v1/auth/status`); + assert.equal(status.status, 200); + + const legacyDevUsersPath = "/api/v1/" + ["dev", "users"].join("/"); + const devUsers = await fetch(`${baseUrl}${legacyDevUsersPath}`, { method: "POST", body: "{}" }); + assert.equal(devUsers.status, 401); + }); +}); + test("password config matrix", () => { const cases = [ { @@ -357,7 +428,6 @@ 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", diff --git a/scripts/clear-session-history.mjs b/scripts/clear-session-history.mjs deleted file mode 100644 index 5a7f88ac..00000000 --- a/scripts/clear-session-history.mjs +++ /dev/null @@ -1,99 +0,0 @@ -#!/usr/bin/env node -/** - * Wipe all dev-user session / conversation history from local storage roots. - * Keeps workspace config (datasources, models, skills, etc.). - * - * Usage: node scripts/clear-session-history.mjs - */ -import { rmSync } from "node:fs"; -import { readdirSync, statSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { DatabaseSync } from "node:sqlite"; - -const repoRoot = resolve(import.meta.dirname, ".."); -const storageRoot = process.env.STORAGE_ROOT_DIR - ? resolve(repoRoot, process.env.STORAGE_ROOT_DIR) - : resolve(repoRoot, "apps/api/storage"); - -const userId = process.env.DEV_USER_ID ?? "dev-user"; -const metaDb = process.env.METADATA_DB_PATH - ? resolve(repoRoot, process.env.METADATA_DB_PATH) - : join(storageRoot, "metadata", "workbench.sqlite"); -const mastraDb = process.env.MASTRA_STORAGE_PATH - ? resolve(repoRoot, process.env.MASTRA_STORAGE_PATH) - : join(storageRoot, "mastra", "agent-state.sqlite"); -const sessionsDir = join(storageRoot, "workspaces", userId, "default", "sessions"); - -function clearMetadata(dbPath) { - if (!statSync(dbPath, { throwIfNoEntry: false })?.isFile()) { - console.log(`[skip] metadata db not found: ${dbPath}`); - return; - } - const db = new DatabaseSync(dbPath); - db.exec("PRAGMA foreign_keys = ON"); - const steps = [ - "DELETE FROM conversation_messages WHERE user_id = ?", - "DELETE FROM conversation_summaries WHERE user_id = ?", - "DELETE FROM run_events WHERE user_id = ?", - "DELETE FROM interactions WHERE user_id = ?", - "DELETE FROM artifacts WHERE user_id = ?", - "DELETE FROM query_history WHERE user_id = ?", - "DELETE FROM long_term_memories WHERE user_id = ?", - "DELETE FROM sql_audit_logs WHERE user_id = ? AND run_id IS NOT NULL", - "UPDATE runs SET parent_run_id = NULL WHERE user_id = ?", - "DELETE FROM runs WHERE user_id = ?", - "DELETE FROM file_asset_refs WHERE user_id = ? AND session_id IS NOT NULL", - "DELETE FROM sessions WHERE user_id = ?", - ]; - for (const sql of steps) { - db.prepare(sql).run(userId); - } - const remaining = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE user_id = ?").get(userId).n; - db.close(); - console.log(`[metadata] sessions remaining for ${userId}: ${remaining}`); -} - -function clearMastra(dbPath) { - if (!statSync(dbPath, { throwIfNoEntry: false })?.isFile()) { - console.log(`[skip] mastra db not found: ${dbPath}`); - return; - } - const db = new DatabaseSync(dbPath); - for (const table of [ - "mastra_messages", - "mastra_thread_state", - "mastra_workflow_snapshot", - "mastra_threads", - ]) { - const before = db.prepare(`SELECT COUNT(*) AS n FROM "${table}"`).get().n; - db.prepare(`DELETE FROM "${table}"`).run(); - console.log(`[mastra] ${table}: ${before} -> 0`); - } - db.close(); -} - -function clearWorkspaceSessionDirs(dir) { - if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) { - console.log(`[skip] sessions dir not found: ${dir}`); - return; - } - let removed = 0; - for (const name of readdirSync(dir)) { - const path = join(dir, name); - if (statSync(path).isDirectory()) { - rmSync(path, { recursive: true, force: true }); - removed += 1; - } - } - console.log(`[workspace] removed ${removed} session directories under ${dir}`); -} - -console.log("Clearing session history…"); -console.log(` metadata: ${metaDb}`); -console.log(` mastra: ${mastraDb}`); -console.log(` dirs: ${sessionsDir}`); -clearMetadata(metaDb); -clearMastra(mastraDb); -clearWorkspaceSessionDirs(sessionsDir); -console.log("Done. Refresh the browser and run in DevTools console:"); -console.log(" localStorage.removeItem('data-tasks:sessions:v2'); location.reload();"); diff --git a/scripts/deploy/cli.mjs b/scripts/deploy/cli.mjs index f7a59b28..3330c059 100644 --- a/scripts/deploy/cli.mjs +++ b/scripts/deploy/cli.mjs @@ -8,6 +8,8 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { deploymentHelp, parseDeployArgs } from "./args.mjs"; import { + assertNativeAuthPublicBaseUrl, + assertNativeBindHosts, ensureDeploymentEnvironment, isCompleteDeploymentConfig, isPlaceholderSecret, @@ -256,19 +258,23 @@ export async function configureDeploymentInteractively(options) { let env = ensured.env; const oldWebPort = env.WEB_PORT; - // Soft upgrade: only AUTH_REGISTRATION_MODE may be auto-filled without re-prompting - // ports. Broader DEFAULTS fills must still go through interactive port selection. + // Soft upgrade: AUTH_REGISTRATION_MODE fill and legacy auth-mode key removal may + // proceed without re-prompting ports. Broader DEFAULTS fills still need port selection. const softUpgradeKeys = new Set(["AUTH_REGISTRATION_MODE"]); const filledOnlySoftUpgradeKeys = !completeBeforeFill && isCompleteDeploymentConfig(env) && ensured.generatedKeys.length > 0 && ensured.generatedKeys.every((key) => softUpgradeKeys.has(key)); + const onlyLegacyAuthModeCleanup = + completeBeforeFill && + (ensured.removedKeys?.length ?? 0) > 0 && + ensured.generatedKeys.length === 0; if ( !reconfigure && !nonInteractive && - (completeBeforeFill || filledOnlySoftUpgradeKeys) && + (completeBeforeFill || filledOnlySoftUpgradeKeys || onlyLegacyAuthModeCleanup) && sourceText?.trim() ) { return { env, envText: text, webText: renderWebEnvironment(env), wrote: false }; @@ -315,12 +321,7 @@ export async function configureDeploymentInteractively(options) { const defaultUrl = updates.AUTH_PUBLIC_BASE_URL; const answer = String((await ask(`浏览器公开访问地址 [${defaultUrl}]:`)) ?? "").trim(); if (answer) { - let url; - try { - url = new URL(answer); - } catch { - throw new Error("AUTH_PUBLIC_BASE_URL must be a valid URL"); - } + const url = assertNativeAuthPublicBaseUrl(answer); const urlPort = url.port || (url.protocol === "https:" ? "443" : "80"); if (String(urlPort) !== String(webPort)) { const confirm = String( @@ -340,13 +341,8 @@ export async function configureDeploymentInteractively(options) { ensured = ensureDeploymentEnvironment(text); text = ensured.text; env = ensured.env; - - if ( - (env.WEB_HOST === "0.0.0.0" || env.WEB_HOST === "::") && - /^https?:\/\/(127\.0\.0\.1|localhost)(:|\/|$)/i.test(env.AUTH_PUBLIC_BASE_URL) - ) { - print("警告:WEB_HOST 对外绑定,但 AUTH_PUBLIC_BASE_URL 仅适合本机访问。远程访问请设置正确的公开地址。"); - } + assertNativeAuthPublicBaseUrl(env.AUTH_PUBLIC_BASE_URL); + assertNativeBindHosts(env); const webText = renderWebEnvironment(env); let wrote = false; diff --git a/scripts/deploy/cli.test.mjs b/scripts/deploy/cli.test.mjs index 64051e60..7fae775f 100644 --- a/scripts/deploy/cli.test.mjs +++ b/scripts/deploy/cli.test.mjs @@ -49,9 +49,8 @@ test("existing complete config skips prompts unless reconfigure", async () => { "SECRET_MASTER_KEY=existing-master-secret-value", "AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310", "AUTH_REGISTRATION_MODE=open", - "DATAFOUNDRY_AUTH_MODE=password", "AUTH_EMAIL_DELIVERY=test", - "WEB_HOST=0.0.0.0", + "WEB_HOST=127.0.0.1", "API_HOST=127.0.0.1", "STORAGE_ROOT_DIR=storage", "METADATA_DB_PATH=storage/metadata/workbench.sqlite" @@ -106,6 +105,8 @@ test("legacy complete env missing AUTH_REGISTRATION_MODE fills default and skips assert.equal(result.env.WEB_PORT, "3310"); assert.equal(result.env.AUTH_REGISTRATION_MODE, "open"); assert.match(result.envText, /^AUTH_REGISTRATION_MODE=open$/m); + assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined); + assert.doesNotMatch(result.envText, /DATAFOUNDRY_AUTH_MODE/); }); test("partial .env is not treated as complete before fill and still prompts", async () => { @@ -127,8 +128,7 @@ test("partial .env is not treated as complete before fill and still prompts", as assert.ok(asked > 0); }); -test("non-interactive never calls ask and warns on loopback public URL with remote bind", async () => { - const lines = []; +test("non-interactive never calls ask and uses loopback defaults", async () => { const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-")); await mkdir(path.join(root, "apps/web"), { recursive: true }); const result = await configureDeploymentInteractively({ @@ -136,13 +136,35 @@ test("non-interactive never calls ask and warns on loopback public URL with remo sourceText: "", reconfigure: false, nonInteractive: true, - processEnv: { WEB_HOST: "0.0.0.0" }, ask: async () => assert.fail("must not prompt"), - print: (line) => lines.push(String(line)), + print: () => {}, probe: async () => ({ available: true, owner: null }) }); assert.equal(result.env.WEB_PORT, "3000"); - assert.match(lines.join("\n"), /本机访问|local-machine|仅适合本机/i); + assert.equal(result.env.WEB_HOST, "127.0.0.1"); + assert.equal(result.env.API_HOST, "127.0.0.1"); + assert.equal(result.env.AUTH_PUBLIC_BASE_URL, "http://127.0.0.1:3000"); + assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined); + assert.equal(result.env.DATALINK_ENABLED, undefined); +}); + +test("non-interactive rejects wildcard bind hosts", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "df-cli-")); + await mkdir(path.join(root, "apps/web"), { recursive: true }); + await assert.rejects( + () => + configureDeploymentInteractively({ + root, + sourceText: "", + reconfigure: false, + nonInteractive: true, + processEnv: { WEB_HOST: "0.0.0.0" }, + ask: async () => assert.fail("must not prompt"), + print: () => {}, + probe: async () => ({ available: true, owner: null }) + }), + /WEB_HOST|loopback|SSH|TLS/i + ); }); test("port menu rejects n with explicit hint", async () => { @@ -189,9 +211,8 @@ test("reconfigure creates backup and keeps secrets", async () => { "AUTH_SESSION_SECRET=existing-session-secret-value", "SECRET_MASTER_KEY=existing-master-secret-value", "AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000", - "DATAFOUNDRY_AUTH_MODE=password", "AUTH_EMAIL_DELIVERY=test", - "WEB_HOST=0.0.0.0", + "WEB_HOST=127.0.0.1", "API_HOST=127.0.0.1", "STORAGE_ROOT_DIR=storage", "METADATA_DB_PATH=storage/metadata/workbench.sqlite" @@ -306,9 +327,8 @@ test("reconfigure reuses managed listening ports only for a verified running sta "AUTH_SESSION_SECRET=existing-session-secret-value", "SECRET_MASTER_KEY=existing-master-secret-value", "AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3310", - "DATAFOUNDRY_AUTH_MODE=password", "AUTH_EMAIL_DELIVERY=test", - "WEB_HOST=0.0.0.0", + "WEB_HOST=127.0.0.1", "API_HOST=127.0.0.1", "STORAGE_ROOT_DIR=storage", "METADATA_DB_PATH=storage/metadata/workbench.sqlite" @@ -426,9 +446,8 @@ test("reconfigure keeps HTTPS public URL when ports are unchanged", async () => "AUTH_SESSION_SECRET=existing-session-secret-value", "SECRET_MASTER_KEY=existing-master-secret-value", "AUTH_PUBLIC_BASE_URL=https://prod.example.com", - "DATAFOUNDRY_AUTH_MODE=password", "AUTH_EMAIL_DELIVERY=test", - "WEB_HOST=0.0.0.0", + "WEB_HOST=127.0.0.1", "API_HOST=127.0.0.1", "STORAGE_ROOT_DIR=storage", "METADATA_DB_PATH=storage/metadata/workbench.sqlite" diff --git a/scripts/deploy/config.mjs b/scripts/deploy/config.mjs index 4256126d..2ba34c81 100644 --- a/scripts/deploy/config.mjs +++ b/scripts/deploy/config.mjs @@ -23,24 +23,45 @@ export function parseDeploymentEnvironment(sourceText = "") { } const DEFAULTS = { - WEB_HOST: "0.0.0.0", + WEB_HOST: "127.0.0.1", WEB_PORT: "3000", API_HOST: "127.0.0.1", API_PORT: "8787", - DATAFOUNDRY_AUTH_MODE: "password", AUTH_EMAIL_DELIVERY: "test", - AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", - // Required by password-mode API boot; open matches .env.example formal-test default. AUTH_REGISTRATION_MODE: "open", STORAGE_ROOT_DIR: "storage", METADATA_DB_PATH: "storage/metadata/workbench.sqlite" }; const SECRET_KEYS = ["AUTH_SESSION_SECRET", "SECRET_MASTER_KEY"]; +/** Removed by password-only cutover; strip on ensure so upgraded .env can boot. */ +const REMOVED_LEGACY_AUTH_KEYS = ["DATAFOUNDRY_AUTH_MODE", "NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE"]; const PLACEHOLDER_SECRETS = new Set(["", "change-me", "replace-me"]); const SENSITIVE_KEY_PATTERN = /KEY|SECRET|TOKEN|PASSWORD|COOKIE|AUTHORIZATION/i; const SENSITIVE_JSON_KEY_PATTERN = /^(?:api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?secret|secret|token|password|authorization|auth)$/i; +export function removeEnvKeys(sourceText, keys) { + const keySet = new Set(keys); + const lines = String(sourceText ?? "").length > 0 + ? String(sourceText).replace(/\r\n/g, "\n").split("\n") + : []; + if (lines.length > 0 && lines.at(-1) === "") lines.pop(); + const removedKeys = []; + const next = []; + for (const line of lines) { + const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line); + if (match && keySet.has(match[1])) { + if (!removedKeys.includes(match[1])) removedKeys.push(match[1]); + continue; + } + next.push(line); + } + return { + text: next.length > 0 ? `${next.join("\n")}\n` : "", + removedKeys + }; +} + export function isPlaceholderSecret(value) { return value == null || PLACEHOLDER_SECRETS.has(String(value).trim()); } @@ -98,7 +119,10 @@ export function updateDeploymentEnvironment(sourceText, updates) { export function ensureDeploymentEnvironment(sourceText, options = {}) { const randomSecret = options.randomSecret ?? (() => randomBytes(32).toString("base64url")); const generateSecrets = options.generateSecrets !== false; - const parsed = parseDeploymentEnvironment(sourceText ?? ""); + const stripped = removeEnvKeys(sourceText ?? "", REMOVED_LEGACY_AUTH_KEYS); + const workingText = stripped.text; + const removedKeys = stripped.removedKeys; + const parsed = parseDeploymentEnvironment(workingText); const updates = {}; const generatedKeys = []; @@ -117,31 +141,30 @@ export function ensureDeploymentEnvironment(sourceText, options = {}) { } } - if ( - (parsed.AUTH_PUBLIC_BASE_URL == null || String(parsed.AUTH_PUBLIC_BASE_URL).trim() === "") && - updates.AUTH_PUBLIC_BASE_URL == null - ) { + if (parsed.AUTH_PUBLIC_BASE_URL == null || String(parsed.AUTH_PUBLIC_BASE_URL).trim() === "") { const webPort = updates.WEB_PORT ?? parsed.WEB_PORT ?? DEFAULTS.WEB_PORT; updates.AUTH_PUBLIC_BASE_URL = `http://127.0.0.1:${webPort}`; - generatedKeys.push("AUTH_PUBLIC_BASE_URL"); + if (!generatedKeys.includes("AUTH_PUBLIC_BASE_URL")) { + generatedKeys.push("AUTH_PUBLIC_BASE_URL"); + } } const text = Object.keys(updates).length > 0 - ? updateDeploymentEnvironment(sourceText ?? "", updates) - : sourceText?.endsWith("\n") || sourceText === "" - ? sourceText ?? "" - : `${sourceText}\n`; + ? updateDeploymentEnvironment(workingText, updates) + : workingText?.endsWith("\n") || workingText === "" + ? workingText + : `${workingText}\n`; const env = { ...parseDeploymentEnvironment(text) }; - return { text, env, generatedKeys }; + delete env.DATAFOUNDRY_AUTH_MODE; + delete env.NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE; + return { text, env, generatedKeys, removedKeys }; } export function renderWebEnvironment(env) { - const authMode = env.DATAFOUNDRY_AUTH_MODE?.trim() || "password"; const apiHost = env.API_HOST?.trim() || "127.0.0.1"; const apiPort = env.API_PORT?.trim() || "8787"; return [ - `NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=${authMode}`, "NEXT_PUBLIC_AGENT_RUNTIME_URL=", "NEXT_PUBLIC_CONFIG_API_URL=", `API_PROXY_TARGET=http://${apiHost}:${apiPort}`, @@ -149,6 +172,49 @@ export function renderWebEnvironment(env) { ].join("\n"); } +function isLoopbackHost(hostname) { + const host = String(hostname ?? "") + .toLowerCase() + .replace(/^\[(.*)\]$/u, "$1"); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; +} + +/** + * Native deploy allows HTTP only on loopback. Non-loopback hosts must use HTTPS + * (or SSH port forwarding to a loopback listener). + */ +export function assertNativeAuthPublicBaseUrl(raw) { + let url; + try { + url = new URL(String(raw ?? "").trim()); + } catch { + throw new Error("AUTH_PUBLIC_BASE_URL must be a valid absolute URL"); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("AUTH_PUBLIC_BASE_URL must use http or https"); + } + if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) { + throw new Error( + "AUTH_PUBLIC_BASE_URL HTTP is only allowed for loopback hosts (127.0.0.1, localhost, ::1). " + + "For remote access use SSH port forwarding to the loopback listener, or configure HTTPS." + ); + } + return url; +} + +export function assertNativeBindHosts(env = {}) { + for (const key of ["WEB_HOST", "API_HOST"]) { + const host = String(env[key] ?? "").trim(); + if (!host) continue; + if (host === "0.0.0.0" || host === "::") { + throw new Error( + `${key}=${host} exposes the service on all interfaces over plain HTTP. ` + + "Native password-only installs bind loopback by default; use SSH forwarding or a TLS reverse proxy." + ); + } + } +} + async function writeAtomic(filePath, content, mode = 0o600) { await mkdir(path.dirname(filePath), { recursive: true }); const tempPath = path.join( diff --git a/scripts/deploy/config.test.mjs b/scripts/deploy/config.test.mjs index ea565f44..c75b31cc 100644 --- a/scripts/deploy/config.test.mjs +++ b/scripts/deploy/config.test.mjs @@ -4,6 +4,8 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; import { + assertNativeAuthPublicBaseUrl, + assertNativeBindHosts, ensureDeploymentEnvironment, isCompleteDeploymentConfig, parseDeploymentEnvironment, @@ -15,12 +17,43 @@ import { test("creates safe defaults without model settings", () => { const result = ensureDeploymentEnvironment("", { randomSecret: () => "generated-secret-value" }); + assert.equal(result.env.WEB_HOST, "127.0.0.1"); + assert.equal(result.env.API_HOST, "127.0.0.1"); assert.equal(result.env.WEB_PORT, "3000"); assert.equal(result.env.API_PORT, "8787"); + assert.equal(result.env.AUTH_PUBLIC_BASE_URL, "http://127.0.0.1:3000"); assert.equal(result.env.AUTH_REGISTRATION_MODE, "open"); + assert.equal(result.env.AUTH_EMAIL_DELIVERY, "test"); assert.equal(result.env.AUTH_SESSION_SECRET, "generated-secret-value"); assert.equal(result.env.SECRET_MASTER_KEY, "generated-secret-value"); assert.equal(result.env.LLM_API_KEY, undefined); + assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined); + assert.doesNotMatch(result.text, /DATAFOUNDRY_AUTH_MODE|NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE/); + assert.doesNotMatch(result.text, /DATALINK_/); +}); + +test("strips legacy DATAFOUNDRY_AUTH_MODE from upgraded env text", () => { + const source = [ + "WEB_PORT=3000", + "API_PORT=8787", + "AUTH_PUBLIC_BASE_URL=http://127.0.0.1:3000", + "AUTH_SESSION_SECRET=existing-session-secret-value", + "SECRET_MASTER_KEY=existing-master-secret-value", + "AUTH_REGISTRATION_MODE=open", + "DATAFOUNDRY_AUTH_MODE=password", + "NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE=password", + "CUSTOM_VALUE=keep-me" + ].join("\n"); + const result = ensureDeploymentEnvironment(source, { generateSecrets: false }); + assert.equal(result.env.DATAFOUNDRY_AUTH_MODE, undefined); + assert.equal(result.env.NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE, undefined); + assert.equal(result.env.CUSTOM_VALUE, "keep-me"); + assert.doesNotMatch(result.text, /DATAFOUNDRY_AUTH_MODE|NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE/); + assert.match(result.text, /^CUSTOM_VALUE=keep-me$/m); + assert.deepEqual(result.removedKeys.sort(), [ + "DATAFOUNDRY_AUTH_MODE", + "NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE" + ]); }); test("fills missing AUTH_REGISTRATION_MODE for legacy complete env", () => { @@ -52,13 +85,32 @@ test("preserves existing secrets and unrelated values", () => { test("renders same-origin Web BFF configuration", () => { const text = renderWebEnvironment({ - DATAFOUNDRY_AUTH_MODE: "password", API_HOST: "127.0.0.1", API_PORT: "8877" }); assert.match(text, /NEXT_PUBLIC_AGENT_RUNTIME_URL=$/m); assert.match(text, /NEXT_PUBLIC_CONFIG_API_URL=$/m); assert.match(text, /API_PROXY_TARGET=http:\/\/127\.0\.0\.1:8877/); + assert.doesNotMatch(text, /NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE/); +}); + +test("rejects non-loopback HTTP public URLs and wildcard bind hosts", () => { + assert.throws( + () => assertNativeAuthPublicBaseUrl("http://example.com"), + /loopback|SSH|HTTPS/i + ); + assert.doesNotThrow(() => assertNativeAuthPublicBaseUrl("http://127.0.0.1:3100")); + assert.doesNotThrow(() => assertNativeAuthPublicBaseUrl("https://prod.example.com")); + assert.throws(() => assertNativeBindHosts({ WEB_HOST: "0.0.0.0" }), /WEB_HOST/); + assert.throws(() => assertNativeBindHosts({ API_HOST: "::" }), /API_HOST/); + assert.doesNotThrow(() => assertNativeBindHosts({ WEB_HOST: "127.0.0.1", API_HOST: "127.0.0.1" })); +}); + +test("generates AUTH_PUBLIC_BASE_URL for a custom Web port", () => { + const result = ensureDeploymentEnvironment("WEB_PORT=3100\n", { + generateSecrets: false + }); + assert.equal(result.env.AUTH_PUBLIC_BASE_URL, "http://127.0.0.1:3100"); }); test("reconfigure creates a backup and atomically writes both files", async () => { diff --git a/scripts/diagnose-tool-result-events.mjs b/scripts/diagnose-tool-result-events.mjs index 43992065..43cdeb2c 100644 --- a/scripts/diagnose-tool-result-events.mjs +++ b/scripts/diagnose-tool-result-events.mjs @@ -42,7 +42,11 @@ const metadataPath = `storage/diagnose-tool-result/${stamp}/metadata.sqlite`; const store = createMetadataStore({ database_path: metadataPath }); const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = process.env.DATAFOUNDRY_USER_ID; +if (!user_id || !process.env.DATAFOUNDRY_WORKSPACE_ID) { + throw new Error("diagnose-tool-result-events requires DATAFOUNDRY_USER_ID and DATAFOUNDRY_WORKSPACE_ID"); +} +const workspace_id = process.env.DATAFOUNDRY_WORKSPACE_ID; const session_id = `diag-session-${stamp}`; const run_id = `diag-run-${stamp}`; const datasource_id = "api-duckdb-demo"; diff --git a/scripts/lib/metadata-test-identity.mjs b/scripts/lib/metadata-test-identity.mjs new file mode 100644 index 00000000..2c75d2df --- /dev/null +++ b/scripts/lib/metadata-test-identity.mjs @@ -0,0 +1,5 @@ +/** + * Re-export the MetadataStore fixture for smoke scripts. + * Prefer importing from `@datafoundry/metadata` / packages/metadata dist in new code. + */ +export { createVerifiedTestIdentity } from "../../packages/metadata/dist/index.js"; diff --git a/scripts/lib/metadata-test-identity.test.mjs b/scripts/lib/metadata-test-identity.test.mjs new file mode 100644 index 00000000..182e1d53 --- /dev/null +++ b/scripts/lib/metadata-test-identity.test.mjs @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createMetadataStore } from "../../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./metadata-test-identity.mjs"; + +test("createVerifiedTestIdentity returns unique verified users without credentials", () => { + const root = mkdtempSync(join(tmpdir(), "metadata-test-identity-")); + try { + const metadata = createMetadataStore({ database_path: join(root, "a.sqlite") }); + const first = createVerifiedTestIdentity(metadata); + const second = createVerifiedTestIdentity(metadata); + + assert.notEqual(first.userId, second.userId); + assert.notEqual(first.workspaceId, second.workspaceId); + assert.notEqual(first.email, second.email); + + const user = metadata.users.getById({ user_id: first.userId }); + assert.ok(user.email_verified_at); + + const credential = metadata.userPasswordCredentials.find({ user_id: first.userId }); + assert.equal(credential, undefined); + + const membership = metadata.workspaceMemberships.get({ + workspace_id: first.workspaceId, + user_id: first.userId, + }); + assert.ok(membership); + assert.equal(membership.role, "owner"); + + metadata.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("fresh metadata schema has no default user and no dev_token column", () => { + const root = mkdtempSync(join(tmpdir(), "metadata-schema-")); + try { + const metadata = createMetadataStore({ database_path: join(root, "fresh.sqlite") }); + const columns = metadata.db.prepare("PRAGMA table_info(users)").all().map((row) => row.name); + assert.ok(!columns.includes("dev_token")); + assert.equal(metadata.users.list().length, 0); + metadata.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/password-only-cutover.test.mjs b/scripts/password-only-cutover.test.mjs new file mode 100644 index 00000000..af9eabfc --- /dev/null +++ b/scripts/password-only-cutover.test.mjs @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { loadPasswordAuthConfig } from "../apps/api/dist/auth/config.js"; + +const ROOT = fileURLToPath(new URL("..", import.meta.url)); + +const FORBIDDEN = [ + "DATAFOUNDRY_AUTH_MODE", + "NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE", + "X-Dev-Token", + "dev-token", + "DEFAULT_DEV_USER", + "upsertDevUser", + "getByDevToken", + "DemoCopilotKitClient", + "--demo", +]; + +const CODE_ROOTS = ["apps", "packages", "scripts"]; +const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"]); +const ENV_FILES = [".env.example", "apps/web/.env.example"]; + +const EXCLUDED_DIR_NAMES = new Set([ + "node_modules", + "dist", + ".next", + "coverage", + ".git", + "storage", + "superpowers", +]); + +/** Paths allowed to mention cutover-forbidden strings (error messages / gate itself). */ +function isAllowlistedSource(relativePath) { + const normalized = relativePath.replaceAll("\\", "/"); + if (normalized === "scripts/password-only-cutover.test.mjs") return true; + if (normalized === "scripts/lib/metadata-test-identity.test.mjs") return true; + if (normalized === "scripts/auth-foundation.test.mjs") return true; + if (normalized === "scripts/deploy/cli.test.mjs") return true; + if (normalized === "scripts/deploy/config.test.mjs") return true; + if (normalized === "scripts/deploy/config.mjs") return true; + if (normalized === "apps/tui/src/no-bare-fetch.guard.test.ts") return true; + if (normalized === "apps/api/src/auth/config.ts") return true; + if (normalized === "apps/api/dist/auth/config.js") return true; + if (normalized.startsWith("packages/metadata/src/index.ts")) return true; + if (normalized.startsWith("packages/metadata/dist/index.js")) return true; + if (normalized.startsWith("docs/")) return true; + if (normalized === "README.md" || normalized === "README_zh.md") return true; + return false; +} + +function walkFiles(dir, out = []) { + for (const entry of readdirSync(dir)) { + if (EXCLUDED_DIR_NAMES.has(entry)) continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + walkFiles(full, out); + continue; + } + const ext = entry.includes(".") ? `.${entry.split(".").pop()}` : ""; + if (CODE_EXTENSIONS.has(ext) || entry.endsWith(".yml") || entry.endsWith(".yaml")) { + out.push(full); + } + } + return out; +} + +function extractFencedBlocks(markdown) { + const blocks = []; + const re = /```[^\n]*\n([\s\S]*?)```/g; + let match; + while ((match = re.exec(markdown))) { + blocks.push(match[1]); + } + return blocks; +} + +test("product code and scripts have no runnable development auth bypasses", () => { + const violations = []; + for (const rootName of CODE_ROOTS) { + const root = join(ROOT, rootName); + for (const full of walkFiles(root)) { + const rel = relative(ROOT, full); + if (isAllowlistedSource(rel)) continue; + const source = readFileSync(full, "utf8"); + for (const token of FORBIDDEN) { + if (source.includes(token)) { + violations.push(`${rel}: ${token}`); + } + } + } + } + for (const envFile of ENV_FILES) { + const full = join(ROOT, envFile); + const source = readFileSync(full, "utf8"); + for (const token of FORBIDDEN) { + if (source.includes(token)) { + violations.push(`${envFile}: ${token}`); + } + } + } + assert.deepEqual(violations, [], `Forbidden development auth tokens remain:\n${violations.join("\n")}`); +}); + +test("CI workflow does not set DATAFOUNDRY_AUTH_MODE", () => { + const ci = readFileSync(join(ROOT, ".github/workflows/ci.yml"), "utf8"); + assert.equal(ci.includes("DATAFOUNDRY_AUTH_MODE"), false); +}); + +test("loadPasswordAuthConfig rejects removed auth modes and requires password settings", () => { + assert.doesNotThrow(() => + loadPasswordAuthConfig({ + DATAFOUNDRY_AUTH_MODE: "password", + AUTH_SESSION_SECRET: "x".repeat(32), + AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", + AUTH_REGISTRATION_MODE: "open", + AUTH_EMAIL_DELIVERY: "test", + }) + ); + assert.throws( + () => + loadPasswordAuthConfig({ + DATAFOUNDRY_AUTH_MODE: "dev", + AUTH_SESSION_SECRET: "x".repeat(32), + AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", + AUTH_REGISTRATION_MODE: "open", + AUTH_EMAIL_DELIVERY: "test", + }), + /DATAFOUNDRY_AUTH_MODE/ + ); + assert.throws( + () => + loadPasswordAuthConfig({ + AUTH_PUBLIC_BASE_URL: "http://127.0.0.1:3000", + AUTH_REGISTRATION_MODE: "open", + AUTH_EMAIL_DELIVERY: "test", + }), + /AUTH_SESSION_SECRET/ + ); +}); + +test("fresh metadata schema has no dev_token and no default user", () => { + const root = mkdtempSync(join(tmpdir(), "password-only-schema-")); + try { + const store = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); + const columns = store.db.prepare("PRAGMA table_info(users)").all().map((row) => row.name); + assert.ok(!columns.includes("dev_token")); + assert.equal(store.users.list().length, 0); + store.close(); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +function walkMarkdownFiles(dir, out = []) { + for (const entry of readdirSync(dir)) { + if (EXCLUDED_DIR_NAMES.has(entry)) continue; + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + walkMarkdownFiles(full, out); + continue; + } + if (entry.endsWith(".md")) out.push(full); + } + return out; +} + +test("markdown fenced blocks omit runnable DATAFOUNDRY_AUTH_MODE examples", () => { + const violations = []; + const files = [join(ROOT, "README.md"), join(ROOT, "README_zh.md")]; + walkMarkdownFiles(join(ROOT, "docs"), files); + for (const full of files) { + const rel = relative(ROOT, full); + const source = readFileSync(full, "utf8"); + for (const block of extractFencedBlocks(source)) { + if (block.includes("DATAFOUNDRY_AUTH_MODE") || block.includes("NEXT_PUBLIC_DATAFOUNDRY_AUTH_MODE")) { + violations.push(rel); + break; + } + } + } + assert.deepEqual(violations, [], `Runnable auth-mode examples remain in fenced docs:\n${violations.join("\n")}`); +}); + +const FORBIDDEN_DOC_PHRASES = [ + "switch local development users", + "switch local dev users", + "Local development identity", + "Default local identity", + "切换本地开发用户", + "本地开发身份", + "本地默认身份", +]; + +test("public docs do not describe removed development identity UX", () => { + const violations = []; + const files = [join(ROOT, "README.md"), join(ROOT, "README_zh.md")]; + walkMarkdownFiles(join(ROOT, "docs"), files); + for (const full of files) { + const rel = relative(ROOT, full); + const source = readFileSync(full, "utf8"); + for (const phrase of FORBIDDEN_DOC_PHRASES) { + if (source.includes(phrase)) { + violations.push(`${rel}: ${phrase}`); + } + } + } + assert.deepEqual( + violations, + [], + `Docs still describe removed development identity UX:\n${violations.join("\n")}` + ); +}); diff --git a/scripts/run-test-every-tool-prompt.mjs b/scripts/run-test-every-tool-prompt.mjs index c9664edf..ad18fada 100644 --- a/scripts/run-test-every-tool-prompt.mjs +++ b/scripts/run-test-every-tool-prompt.mjs @@ -14,6 +14,7 @@ import { import { LocalDataGateway } from "../packages/data-gateway/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { LocalKnowledgeService } from "../packages/knowledge/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const USER_PROMPT = "测试每一个tool"; @@ -45,9 +46,13 @@ mkdirSync(storageDir, { recursive: true }); const metadataPath = `${storageDir}/metadata.sqlite`; const taskDbPath = `${storageDir}/task-state.sqlite`; const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const session_id = `test-every-tool-session-${stamp}`; const run_id = `test-every-tool-run-${stamp}`; const datasource_id = "api-duckdb-demo"; diff --git a/scripts/smoke-agent-runtime.mjs b/scripts/smoke-agent-runtime.mjs index 87d3a860..42afbf21 100644 --- a/scripts/smoke-agent-runtime.mjs +++ b/scripts/smoke-agent-runtime.mjs @@ -13,14 +13,19 @@ import { EventType } from "@ag-ui/core"; import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const stamp = Date.now(); const metadataPath = `storage/agent-smoke/${stamp}/metadata.sqlite`; const sqlitePath = `storage/agent-smoke/${stamp}/large.sqlite`; const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const session_id = "agent-smoke-session"; const run_id = "agent-smoke-run"; const datasource_id = "agent-sqlite-large"; diff --git a/scripts/smoke-auth.mjs b/scripts/smoke-auth.mjs index 7992f20a..71aa463d 100644 --- a/scripts/smoke-auth.mjs +++ b/scripts/smoke-auth.mjs @@ -8,7 +8,6 @@ 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"; diff --git a/scripts/smoke-collaboration-tools.mjs b/scripts/smoke-collaboration-tools.mjs index d62ddba1..342fe1e3 100644 --- a/scripts/smoke-collaboration-tools.mjs +++ b/scripts/smoke-collaboration-tools.mjs @@ -12,10 +12,15 @@ import { } from "../apps/api/dist/interaction-runtime-adapter.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { rmSync } from "node:fs"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const storageDir = `storage/collaboration-smoke/${Date.now()}`; const store = createMetadataStore({ database_path: `${storageDir}/metadata.sqlite` }); -const userId = "dev-user"; +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + + const sessionId = "collaboration-session"; const runId = "collaboration-run"; diff --git a/scripts/smoke-config-api.mjs b/scripts/smoke-config-api.mjs index 33b890f2..f9eb2d07 100644 --- a/scripts/smoke-config-api.mjs +++ b/scripts/smoke-config-api.mjs @@ -27,7 +27,6 @@ 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"; diff --git a/scripts/smoke-conversation-memory.mjs b/scripts/smoke-conversation-memory.mjs index 1bdd4fbc..a9e9f933 100644 --- a/scripts/smoke-conversation-memory.mjs +++ b/scripts/smoke-conversation-memory.mjs @@ -13,11 +13,16 @@ import { createMastraConversationMemoryBridge } from "../packages/agent-runtime/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/conversation-memory-smoke-${Date.now()}.sqlite`; const memoryDatabasePath = `storage/metadata/conversation-memory-shadow-${Date.now()}.sqlite`; const consumingMemoryDatabasePath = `storage/metadata/conversation-memory-consuming-${Date.now()}.sqlite`; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + let memoryRuntime; let consumingMemoryRuntime; @@ -28,7 +33,7 @@ try { }); const memoryBridge = createMastraConversationMemoryBridge({ memory: memoryRuntime.memory }); const consumingMemoryBridge = createMastraConversationMemoryBridge({ memory: consumingMemoryRuntime.memory }); - const userId = "dev-user"; + const sessionId = "conversation-memory-session"; const firstRunId = "conversation-memory-run-1"; const secondRunId = "conversation-memory-run-2"; @@ -677,7 +682,7 @@ function assertEqual(actual, expected, message) { function createHistoryRecord(id, role, content, position) { return { id, - user_id: "dev-user", + user_id: userId, session_id: "conversation-memory-session", run_id: `history-${id}`, role, @@ -694,7 +699,7 @@ function createHistoryRecord(id, role, content, position) { function createSummaryRecord(id, fromPosition, toPosition, summaryText) { return { id, - user_id: "dev-user", + user_id: userId, session_id: "conversation-memory-session", from_position: fromPosition, to_position: toPosition, diff --git a/scripts/smoke-copilotkit-context.mjs b/scripts/smoke-copilotkit-context.mjs index 710da5f3..6fa284ce 100644 --- a/scripts/smoke-copilotkit-context.mjs +++ b/scripts/smoke-copilotkit-context.mjs @@ -1,8 +1,11 @@ +import { randomUUID } from "node:crypto"; import { EventType } from "@ag-ui/core"; import { extractDatasourceId, extractEffectiveRunConfig, extractLastUserText } from "../apps/api/dist/run-input.js"; import { createDataFoundry, createDataFoundryRunContext, DATA_AGENT_TOOL_NAMES } from "../packages/agent-runtime/dist/index.js"; import { TaskPlanProjector } from "../apps/api/dist/task-plan-projector.js"; +const userId = randomUUID(); + const baseInput = { threadId: "thread-smoke", runId: "run-smoke", @@ -164,7 +167,7 @@ assert( assert( createDataFoundryRunContext({ - user_id: "dev-user", + user_id: userId, session_id: "thread-smoke", run_id: "run-smoke", user_input: "你好", @@ -179,7 +182,7 @@ const noDatasourceAgent = await createDataFoundry({ messages: [], modelProvider: { kind: "mastra-router", model: "openai/smoke", model_name: "smoke-model" }, runContext: createDataFoundryRunContext({ - user_id: "dev-user", + user_id: userId, session_id: "thread-smoke", run_id: "run-smoke", user_input: "你好", @@ -193,7 +196,7 @@ assert( ); const projector = new TaskPlanProjector({ - user_id: "dev-user", + user_id: userId, session_id: "thread-smoke", run_id: "run-smoke", user_input: "analyze orders", diff --git a/scripts/smoke-copilotkit-run.mjs b/scripts/smoke-copilotkit-run.mjs index 58b00836..811894a8 100644 --- a/scripts/smoke-copilotkit-run.mjs +++ b/scripts/smoke-copilotkit-run.mjs @@ -13,7 +13,6 @@ 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"; diff --git a/scripts/smoke-copilotkit.mjs b/scripts/smoke-copilotkit.mjs index 076e3ba3..a4a58da7 100644 --- a/scripts/smoke-copilotkit.mjs +++ b/scripts/smoke-copilotkit.mjs @@ -12,7 +12,6 @@ const child = spawn("npm", ["--workspace", "@datafoundry/api", "run", "dev"], { API_HOST: "127.0.0.1", API_PORT: apiPort, 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", diff --git a/scripts/smoke-data-gateway.mjs b/scripts/smoke-data-gateway.mjs index f989c4fb..b9908252 100644 --- a/scripts/smoke-data-gateway.mjs +++ b/scripts/smoke-data-gateway.mjs @@ -6,6 +6,7 @@ import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; import duckdb from "duckdb"; import writeXlsxFile from "write-excel-file/node"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const stamp = Date.now(); const root = `storage/data-gateway-smoke/${stamp}`; @@ -26,8 +27,12 @@ writeFileSync( await createXlsxFixture(xlsxPath); const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const clickHouseServer = createClickHouseFixtureServer(); await new Promise((resolve) => clickHouseServer.listen(0, "127.0.0.1", resolve)); const clickHouseAddress = clickHouseServer.address(); diff --git a/scripts/smoke-files.mjs b/scripts/smoke-files.mjs index 91ad797b..a993d587 100644 --- a/scripts/smoke-files.mjs +++ b/scripts/smoke-files.mjs @@ -7,17 +7,20 @@ import { LocalArtifactService } from "../packages/artifacts/dist/index.js"; import { fileAssetRefDto, LocalFileAssetService } from "../packages/files/dist/index.js"; import { LocalKnowledgeService } from "../packages/knowledge/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "open-data-foundry-files-smoke-")); const store = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const files = new LocalFileAssetService(store, { storageRoot: join(root, "files") }); const artifacts = new LocalArtifactService(store, files); const knowledge = new LocalKnowledgeService(store); -const userId = "dev-user"; -const workspaceId = "default"; const sessionId = "files-smoke-session"; const runId = "files-smoke-run"; diff --git a/scripts/smoke-knowledge-retrieval-policy.mjs b/scripts/smoke-knowledge-retrieval-policy.mjs index cfd1cc8b..f3383cef 100644 --- a/scripts/smoke-knowledge-retrieval-policy.mjs +++ b/scripts/smoke-knowledge-retrieval-policy.mjs @@ -2,18 +2,23 @@ import { rmSync } from "node:fs"; import { LocalKnowledgeService } from "../packages/knowledge/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/knowledge-retrieval-policy-${Date.now()}.sqlite`; -const userId = "dev-user"; + const collectionId = "policy-kb"; const chunkPolicyCollectionId = "chunk-policy-kb"; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const knowledge = new LocalKnowledgeService(store); try { store.configResources.upsert({ id: collectionId, - workspace_id: "default", + workspace_id: workspaceId, user_id: userId, kind: "knowledge-base", name: "Policy KB", @@ -59,7 +64,7 @@ try { const maxScore = Math.max(...explicitTopK.map((chunk) => chunk.score)); store.configResources.upsert({ id: collectionId, - workspace_id: "default", + workspace_id: workspaceId, user_id: userId, kind: "knowledge-base", name: "Policy KB", @@ -76,7 +81,7 @@ try { store.configResources.upsert({ id: chunkPolicyCollectionId, - workspace_id: "default", + workspace_id: workspaceId, user_id: userId, kind: "knowledge-base", name: "Chunk Policy KB", diff --git a/scripts/smoke-long-term-memory.mjs b/scripts/smoke-long-term-memory.mjs index b02e2971..03aa50bf 100644 --- a/scripts/smoke-long-term-memory.mjs +++ b/scripts/smoke-long-term-memory.mjs @@ -12,13 +12,18 @@ import { } from "../apps/api/dist/long-term-memory.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { rmSync } from "node:fs"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/long-term-memory-smoke-${Date.now()}.sqlite`; -const userId = "dev-user"; + const sessionId = "long-term-memory-session"; const runId = "long-term-memory-run"; const datasourceId = "api-duckdb-demo"; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + try { store.sessions.create({ diff --git a/scripts/smoke-memory-recall-shadow.mjs b/scripts/smoke-memory-recall-shadow.mjs index ea6a967e..2dfe03ba 100644 --- a/scripts/smoke-memory-recall-shadow.mjs +++ b/scripts/smoke-memory-recall-shadow.mjs @@ -1,15 +1,20 @@ import { LocalKnowledgeService } from "../packages/knowledge/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { rmSync } from "node:fs"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/memory-recall-shadow-${Date.now()}.sqlite`; -const userId = "dev-user"; + const sessionId = "memory-recall-shadow-session"; const runId = "memory-recall-shadow-run"; const datasourceId = "api-duckdb-demo"; const collectionId = "memory-shadow-kb"; const query = "GMV refund rate orders"; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const knowledge = new LocalKnowledgeService(store); try { diff --git a/scripts/smoke-metadata.mjs b/scripts/smoke-metadata.mjs index 9a90da2e..16e3ba16 100644 --- a/scripts/smoke-metadata.mjs +++ b/scripts/smoke-metadata.mjs @@ -3,12 +3,17 @@ import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { EventType } from "@ag-ui/core"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/metadata-smoke-${Date.now()}.sqlite`; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + try { - const userId = "dev-user"; + const sessionId = "session-smoke"; const runId = "run-smoke"; @@ -86,13 +91,10 @@ try { throw new Error("Expected user-scoped replay to hide another user's run events"); } - const otherUserId = "metadata-smoke-other-user"; - store.users.upsertDevUser({ - id: otherUserId, - email: "metadata-smoke-other@example.com", - display_name: "Metadata Smoke Other User", - dev_token: "metadata-smoke-other-token" + const otherIdentity = createVerifiedTestIdentity(store, { + email: "metadata-smoke-other@example.test", }); + const otherUserId = otherIdentity.userId; store.sessions.create({ user_id: otherUserId, id: sessionId, diff --git a/scripts/smoke-protocol-recovery.mjs b/scripts/smoke-protocol-recovery.mjs index 150e09d5..839f5e21 100644 --- a/scripts/smoke-protocol-recovery.mjs +++ b/scripts/smoke-protocol-recovery.mjs @@ -4,10 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "datafoundry-protocol-recovery-")); const databasePath = join(root, "metadata.sqlite"); -const userId = "dev-user"; + const sessionId = "protocol-session"; const runId = "protocol-run"; diff --git a/scripts/smoke-run-config-disabled.mjs b/scripts/smoke-run-config-disabled.mjs index c5e9d2f2..a326b337 100644 --- a/scripts/smoke-run-config-disabled.mjs +++ b/scripts/smoke-run-config-disabled.mjs @@ -11,13 +11,14 @@ process.env.LLM_API_KEY = "r020-smoke-key"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { resolveRunConfig } from "../apps/api/dist/run-config-resolver.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "r020-")); const store = createMetadataStore({ database_path: join(root, "m.sqlite") }); -store.users.upsertDevUser({ id: "dev-user", email: "dev@local", display_name: "dev", dev_token: "dev-token" }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; -const userId = "dev-user"; -const workspaceId = "default"; try { // Seed one enabled KB and one default_enabled=false KB. diff --git a/scripts/smoke-run-config-mcp-degraded.mjs b/scripts/smoke-run-config-mcp-degraded.mjs index fc41aab8..b9d5285a 100644 --- a/scripts/smoke-run-config-mcp-degraded.mjs +++ b/scripts/smoke-run-config-mcp-degraded.mjs @@ -9,13 +9,14 @@ process.env.LLM_API_KEY = "mcp-smoke-key"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { resolveRunConfig } from "../apps/api/dist/run-config-resolver.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "mcp-degraded-")); const store = createMetadataStore({ database_path: join(root, "m.sqlite") }); -store.users.upsertDevUser({ id: "dev-user", email: "dev@local", display_name: "dev", dev_token: "dev-token" }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; -const userId = "dev-user"; -const workspaceId = "default"; try { store.configResources.upsert({ diff --git a/scripts/smoke-run-finalizer.mjs b/scripts/smoke-run-finalizer.mjs index 8c81f77c..edda9828 100644 --- a/scripts/smoke-run-finalizer.mjs +++ b/scripts/smoke-run-finalizer.mjs @@ -7,12 +7,17 @@ import { join } from "node:path"; import { ConversationMemoryService } from "../apps/api/dist/conversation-memory.js"; import { RunFinalizer } from "../apps/api/dist/run-finalizer.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "datafoundry-run-finalizer-")); -const userId = "dev-user"; + try { const metadataStore = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); +const __testIdentity = createVerifiedTestIdentity(metadataStore); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + await runCanceledDraftScenario(metadataStore); await runCanceledEmptyDraftScenario(metadataStore); @@ -163,6 +168,6 @@ function createFinalizer(metadataStore, sessionId, runId, observer, emitted) { sessionDir: join(root, "sessions", sessionId), sessionId, userId, - workspaceId: "default", + workspaceId, }); } diff --git a/scripts/smoke-run-identity.mjs b/scripts/smoke-run-identity.mjs index 869afdcc..6a2e49c1 100644 --- a/scripts/smoke-run-identity.mjs +++ b/scripts/smoke-run-identity.mjs @@ -7,12 +7,17 @@ import { } from "../apps/api/dist/run-identity.js"; import { resolveRunIdentity } from "../apps/api/dist/run-identity-orchestrator.js"; import { RunEventWriter, createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const databasePath = `storage/metadata/run-identity-smoke-${Date.now()}.sqlite`; const store = createMetadataStore({ database_path: databasePath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + try { - const userId = "dev-user"; + const sessionId = "identity-session"; const runId = "identity-run"; const requestInput = { diff --git a/scripts/smoke-server-datasources-e2e.mjs b/scripts/smoke-server-datasources-e2e.mjs index 32fe3421..acb2d4f1 100644 --- a/scripts/smoke-server-datasources-e2e.mjs +++ b/scripts/smoke-server-datasources-e2e.mjs @@ -10,7 +10,6 @@ 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"; diff --git a/scripts/smoke-skills.mjs b/scripts/smoke-skills.mjs index a4a3247d..842d5049 100644 --- a/scripts/smoke-skills.mjs +++ b/scripts/smoke-skills.mjs @@ -17,13 +17,16 @@ import { } from "../packages/agent-runtime/dist/testing.js"; import { createRunWorkspace } from "../packages/agent-runtime/dist/tools/workspace-factory.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const root = mkdtempSync(join(tmpdir(), "open-data-foundry-skills-smoke-")); const metadataStore = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); +const __testIdentity = createVerifiedTestIdentity(metadataStore); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const fileAssetService = new LocalFileAssetService(metadataStore, { storageRoot: join(root, "files") }); -const userId = "dev-user"; -const workspaceId = "default"; const packageBody = Buffer.from(`--- name: sql-analysis-smoke description: Use for SQL analysis smoke tests. diff --git a/scripts/smoke-sql-readonly.mjs b/scripts/smoke-sql-readonly.mjs index 29d4e422..9ea9765b 100644 --- a/scripts/smoke-sql-readonly.mjs +++ b/scripts/smoke-sql-readonly.mjs @@ -2,6 +2,7 @@ import { LocalDataGateway } from "../packages/data-gateway/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { mkdirSync } from "node:fs"; import { DatabaseSync } from "node:sqlite"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const stamp = Date.now(); const root = `storage/sql-smoke/${stamp}`; @@ -11,8 +12,12 @@ mkdirSync(root, { recursive: true }); createSqliteFixture(sqlitePath); const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const session_id = "sql-smoke-session"; const run_id = "sql-smoke-run"; diff --git a/scripts/smoke-task-state.mjs b/scripts/smoke-task-state.mjs index 6125dc46..b9f644af 100644 --- a/scripts/smoke-task-state.mjs +++ b/scripts/smoke-task-state.mjs @@ -1,5 +1,6 @@ import { taskCheckTool, taskCompleteTool, taskWriteTool } from "@mastra/core/harness"; import { Mastra } from "@mastra/core/mastra"; +import { randomUUID } from "node:crypto"; import { rmSync } from "node:fs"; import { @@ -13,7 +14,7 @@ const smokeRoot = `storage/task-state-smoke/${Date.now()}`; const databasePath = `${smokeRoot}/task-state.sqlite`; const workingMemoryDatabasePath = `${smokeRoot}/working-memory.sqlite`; const threadId = "task-state-thread"; -const resourceId = "dev-user"; +const resourceId = randomUUID(); try { const firstRuntime = await createAgentMemoryRuntime(databasePath); diff --git a/scripts/smoke-tool-state-isolation.mjs b/scripts/smoke-tool-state-isolation.mjs index 99664814..f75dd442 100644 --- a/scripts/smoke-tool-state-isolation.mjs +++ b/scripts/smoke-tool-state-isolation.mjs @@ -10,6 +10,7 @@ import { createMetadataStore } from "../packages/metadata/dist/index.js"; import { mkdirSync, rmSync } from "node:fs"; import { dirname } from "node:path"; import { DatabaseSync } from "node:sqlite"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const stamp = Date.now(); const storageDir = `storage/tool-state-smoke/${stamp}`; @@ -17,9 +18,13 @@ const metadataPath = `${storageDir}/metadata.sqlite`; const sqlitePathA = `${storageDir}/orders-a.sqlite`; const sqlitePathB = `${storageDir}/orders-b.sqlite`; const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const session_id = "tool-state-session"; const run_id = "tool-state-run"; const datasource_a = "ds-orders-a"; diff --git a/scripts/smoke-trace-sections.mjs b/scripts/smoke-trace-sections.mjs index b724c2bb..e326eed5 100644 --- a/scripts/smoke-trace-sections.mjs +++ b/scripts/smoke-trace-sections.mjs @@ -9,6 +9,7 @@ import { } from "../packages/metadata/dist/index.js"; import { TraceSectionCoordinator } from "../apps/api/dist/trace-section-coordinator.js"; import { buildSessionTraceDag } from "../apps/api/dist/trace-dag.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; loadDotEnv(); @@ -18,10 +19,14 @@ if (modelProvider.kind === "mock") { } const stamp = Date.now(); -const userId = "dev-user"; + const sessionId = `trace-section-session-${stamp}`; const runId = `trace-section-run-${stamp}`; const store = createMetadataStore({ database_path: `storage/trace-sections/${stamp}/metadata.sqlite` }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + try { store.sessions.create({ user_id: userId, id: sessionId, title: "Trace section smoke" }); diff --git a/scripts/smoke-tui-auth-sharing.mjs b/scripts/smoke-tui-auth-sharing.mjs index eb170413..9bd34e88 100644 --- a/scripts/smoke-tui-auth-sharing.mjs +++ b/scripts/smoke-tui-auth-sharing.mjs @@ -18,7 +18,6 @@ const { } = await import(tuiAuthUrl); const root = mkdtempSync(join(tmpdir(), "datafoundry-tui-auth-share-")); -process.env.DATAFOUNDRY_AUTH_MODE = "password"; process.env.AUTH_SESSION_SECRET = "tui-share-session-secret-with-32-bytes!"; process.env.AUTH_PUBLIC_BASE_URL = "http://127.0.0.1"; process.env.AUTH_EMAIL_DELIVERY = "test"; diff --git a/scripts/stack-runtime-config.mjs b/scripts/stack-runtime-config.mjs index 9ac88130..7b8540e8 100644 --- a/scripts/stack-runtime-config.mjs +++ b/scripts/stack-runtime-config.mjs @@ -10,8 +10,8 @@ export function resolveStackRuntimeConfig(env = process.env) { return { API_HOST: env.API_HOST?.trim() || "127.0.0.1", API_PORT: port(env.API_PORT, 8787, "API_PORT"), - WEB_HOST: env.WEB_HOST?.trim() || "0.0.0.0", - WEB_PORT: port(env.WEB_PORT, 3000, "WEB_PORT"), + WEB_HOST: env.WEB_HOST?.trim() || "127.0.0.1", + WEB_PORT: port(env.WEB_PORT, 3000, "WEB_PORT") }; } diff --git a/scripts/stack-runtime-config.test.mjs b/scripts/stack-runtime-config.test.mjs index c5b13694..a846bc3a 100644 --- a/scripts/stack-runtime-config.test.mjs +++ b/scripts/stack-runtime-config.test.mjs @@ -10,7 +10,7 @@ test("uses native deployment defaults", () => { const config = resolveStackRuntimeConfig({}); assert.equal(config.API_HOST, "127.0.0.1"); assert.equal(config.API_PORT, "8787"); - assert.equal(config.WEB_HOST, "0.0.0.0"); + assert.equal(config.WEB_HOST, "127.0.0.1"); assert.equal(config.WEB_PORT, "3000"); }); diff --git a/scripts/test-builtin-dtc-growth-datasource.mjs b/scripts/test-builtin-dtc-growth-datasource.mjs index 456b7b9b..e40b5bc3 100644 --- a/scripts/test-builtin-dtc-growth-datasource.mjs +++ b/scripts/test-builtin-dtc-growth-datasource.mjs @@ -15,6 +15,7 @@ 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"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const repoFixture = join(repoRoot, "storage/fixtures/dtc-growth-demo.sqlite"); @@ -41,18 +42,15 @@ test("ensureBuiltinDtcGrowthDatasource copies fixture and registers datasource i database_path: join(root, "metadata.sqlite"), secret_master_key: "dtc-growth-builtin-test-key" }); - metadataStore.users.upsertDevUser({ - id: "user-a", - email: "user-a@example.com", - display_name: "User A", - dev_token: "user-a-token" + const { userId, workspaceId } = createVerifiedTestIdentity(metadataStore, { + displayName: "Builtin DTC Growth" }); try { const first = ensureBuiltinDtcGrowthDatasource({ metadataStore, - userId: "user-a", - workspaceId: "default", + userId, + workspaceId, fixturePath: repoFixture, workspaceRoot }); @@ -60,7 +58,7 @@ test("ensureBuiltinDtcGrowthDatasource copies fixture and registers datasource i assert.ok(first.filePath && existsSync(first.filePath)); const record = metadataStore.dataSources.get({ - user_id: "user-a", + user_id: userId, datasource_id: DTC_GROWTH_DATASOURCE_ID }); assert.equal(record.name, DTC_GROWTH_DATASOURCE_NAME); @@ -72,21 +70,21 @@ test("ensureBuiltinDtcGrowthDatasource copies fixture and registers datasource i const second = ensureBuiltinDtcGrowthDatasource({ metadataStore, - userId: "user-a", - workspaceId: "default", + userId, + workspaceId, fixturePath: repoFixture, workspaceRoot }); assert.equal(second.action, "skipped"); assert.equal( - metadataStore.dataSources.list({ user_id: "user-a" }).filter((item) => item.id === DTC_GROWTH_DATASOURCE_ID).length, + metadataStore.dataSources.list({ user_id: userId }).filter((item) => item.id === DTC_GROWTH_DATASOURCE_ID).length, 1 ); // Broken path → repair without duplicating. rmSync(first.filePath, { force: true }); metadataStore.dataSources.create({ - user_id: "user-a", + user_id: userId, id: DTC_GROWTH_DATASOURCE_ID, name: DTC_GROWTH_DATASOURCE_NAME, type: "sqlite", @@ -96,31 +94,31 @@ test("ensureBuiltinDtcGrowthDatasource copies fixture and registers datasource i }); const repaired = ensureBuiltinDtcGrowthDatasource({ metadataStore, - userId: "user-a", - workspaceId: "default", + userId, + workspaceId, fixturePath: repoFixture, workspaceRoot }); assert.equal(repaired.action, "repaired"); assert.ok(repaired.filePath && existsSync(repaired.filePath)); const repairedRecord = metadataStore.dataSources.get({ - user_id: "user-a", + user_id: userId, datasource_id: DTC_GROWTH_DATASOURCE_ID }); assert.equal(JSON.parse(repairedRecord.config_json).path, repaired.filePath); // Deleted datasource is not revived. - metadataStore.dataSources.delete({ user_id: "user-a", datasource_id: DTC_GROWTH_DATASOURCE_ID }); + metadataStore.dataSources.delete({ user_id: userId, datasource_id: DTC_GROWTH_DATASOURCE_ID }); const afterDelete = ensureBuiltinDtcGrowthDatasource({ metadataStore, - userId: "user-a", - workspaceId: "default", + userId, + workspaceId, fixturePath: repoFixture, workspaceRoot }); assert.equal(afterDelete.action, "skipped"); assert.equal( - metadataStore.dataSources.find({ user_id: "user-a", datasource_id: DTC_GROWTH_DATASOURCE_ID })?.status, + metadataStore.dataSources.find({ user_id: userId, datasource_id: DTC_GROWTH_DATASOURCE_ID })?.status, "deleted" ); } finally { @@ -131,7 +129,6 @@ 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"; diff --git a/scripts/test-kb-skill-whitelist-fixes.mjs b/scripts/test-kb-skill-whitelist-fixes.mjs index 284c4e23..aefde966 100644 --- a/scripts/test-kb-skill-whitelist-fixes.mjs +++ b/scripts/test-kb-skill-whitelist-fixes.mjs @@ -19,6 +19,7 @@ import { } from "../packages/skills/dist/index.js"; import { LocalFileAssetService } from "../packages/files/dist/index.js"; import { createMetadataStore } from "../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "./lib/metadata-test-identity.mjs"; test("decodeMultipartFilename recovers UTF-8 Chinese names misread as Latin-1", () => { const original = "张三-2024-ProAgent.pdf"; @@ -45,9 +46,12 @@ test("extractEffectiveRunConfig defaults maxSkills to 20", () => { test("selectSkillsForRun keeps activeSkillId when maxSkills truncates", async () => { const root = mkdtempSync(join(tmpdir(), "skill-active-truncate-")); const metadataStore = createMetadataStore({ database_path: join(root, "metadata.sqlite") }); +const __testIdentity = createVerifiedTestIdentity(metadataStore); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const fileAssetService = new LocalFileAssetService(metadataStore, { storageRoot: join(root, "files") }); - const userId = "dev-user"; - const workspaceId = "default"; + const skillIds = []; for (let index = 0; index < 8; index += 1) { diff --git a/scripts/verify-tools/data-tools.mjs b/scripts/verify-tools/data-tools.mjs index bdfe4989..2b800f0b 100644 --- a/scripts/verify-tools/data-tools.mjs +++ b/scripts/verify-tools/data-tools.mjs @@ -11,6 +11,7 @@ import { } 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 { createVerifiedTestIdentity } from "../lib/metadata-test-identity.mjs"; const envPath = join(process.cwd(), ".env"); try { @@ -33,9 +34,13 @@ const metadataPath = `storage/verify-tools/data-${stamp}/metadata.sqlite`; mkdirSync(`storage/verify-tools/data-${stamp}`, { recursive: true }); const store = createMetadataStore({ database_path: metadataPath }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; + const gateway = new LocalDataGateway(store); -const user_id = "dev-user"; +const user_id = userId; const session_id = `verify-session-${stamp}`; const run_id = `verify-run-${stamp}`; const datasource_id = "api-duckdb-demo"; diff --git a/scripts/verify-tools/knowledge-tool.mjs b/scripts/verify-tools/knowledge-tool.mjs index 79d1709a..425e33d2 100644 --- a/scripts/verify-tools/knowledge-tool.mjs +++ b/scripts/verify-tools/knowledge-tool.mjs @@ -12,6 +12,7 @@ import { import { LocalDataGateway } from "../../packages/data-gateway/dist/index.js"; import { LocalKnowledgeService } from "../../packages/knowledge/dist/index.js"; import { createMetadataStore } from "../../packages/metadata/dist/index.js"; +import { createVerifiedTestIdentity } from "../lib/metadata-test-identity.mjs"; const envPath = join(process.cwd(), ".env"); try { @@ -38,8 +39,12 @@ const store = createMetadataStore({ database_path: metadataPath, ...(process.env.SECRET_MASTER_KEY ? { secret_master_key: process.env.SECRET_MASTER_KEY } : {}), }); +const __testIdentity = createVerifiedTestIdentity(store); +const userId = __testIdentity.userId; +const workspaceId = __testIdentity.workspaceId; -const user_id = "dev-user"; + +const user_id = userId; const session_id = `verify-kb-session-${stamp}`; const run_id = `verify-kb-run-${stamp}`; const datasource_id = "api-duckdb-demo"; @@ -221,7 +226,7 @@ try { if (hasEmbeddingCreds) { store.configResources.upsert({ id: collection_id, - workspace_id: "default", + workspace_id: workspaceId, user_id, kind: "knowledge-base", name: "Verify KB", diff --git a/scripts/verify-tools/task-collab-tools.mjs b/scripts/verify-tools/task-collab-tools.mjs index c53901be..a0ed2cc1 100644 --- a/scripts/verify-tools/task-collab-tools.mjs +++ b/scripts/verify-tools/task-collab-tools.mjs @@ -1,6 +1,7 @@ /** * Deterministic runtime verification for task + collaboration tools (no LLM). */ +import { randomUUID } from "node:crypto"; import { rmSync } from "node:fs"; import { Mastra } from "@mastra/core/mastra"; import { @@ -20,7 +21,7 @@ const stamp = Date.now(); const storageDir = `storage/verify-tools/task-${stamp}`; const databasePath = `${storageDir}/state.sqlite`; const threadId = `verify-thread-${stamp}`; -const resourceId = "dev-user"; +const resourceId = randomUUID(); function makeExecCtx(toolName, mastraCtx) { const customChunks = [];