Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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:
#
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions README_zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 14 additions & 30 deletions apps/api/src/auth/config.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -69,11 +67,19 @@ export function validateAuthPublicUrl(raw: string): {
}

export function loadPasswordAuthConfig(env: Record<string, string | undefined>): 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 ?? "",
Expand All @@ -91,35 +97,13 @@ export function loadPasswordAuthConfig(env: Record<string, string | undefined>):
: {})
};
}
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;
Expand Down
74 changes: 3 additions & 71 deletions apps/api/src/config-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand Down Expand Up @@ -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<ConfigApiContext>
): Promise<ConfigApiResponse> => {
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<ConfigApiContext>
Expand Down Expand Up @@ -3577,45 +3537,17 @@ const listConfig = (context: Required<ConfigApiContext>, kind: ConfigResourceKin
kind
}).map(configResourceDto);

const devIdentityUserDto = (user: UserRecord): Record<string, unknown> => ({
const authUserDto = (user: UserRecord): Record<string, unknown> => ({
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<string, unknown> => ({
id: workspaceId,
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<string, unknown> => {
const config = parseRecord(record.config_json);
return {
Expand Down
22 changes: 12 additions & 10 deletions apps/api/src/protocol-state-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,32 @@ 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", () => {
it("restores a persisted protocol segment", () => {
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",
Expand All @@ -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]);
Expand All @@ -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({
Expand Down
15 changes: 8 additions & 7 deletions apps/api/src/run-checkpoint-projector.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,24 +12,25 @@ 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",
revision: 2,
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", {
Expand All @@ -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
Expand Down
Loading