fix(dev): restore automatic local owner sign-in

This commit is contained in:
tianma-if committed 2026-09-03 19:29:58 +08:00
1 parent bae23dc060
commit ebed41a887
9 files changed
+279 -9

No files matched your search

+2
View File
@@ -184,6 +184,8 @@ bun install
bun run dev
```
Local development signs in automatically; fresh databases use `owner` / `edgeever-local-dev`. Log out to test the login screen.
## Project Structure
```text
+2
View File
@@ -182,6 +182,8 @@ bun install
bun run dev
```
本地开发默认自动登录,首次账号为 `owner` / `edgeever-local-dev`;测试登录页可主动退出登录。
## 目录结构
```text
+83
View File
@@ -0,0 +1,83 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import { localDevelopmentAuth } from "./local-dev-auth.ts";
const originalProfile = process.env.EDGE_EVER_DEVELOPMENT_PROFILE;
const originalAutoLogin = process.env.EDGE_EVER_LOCAL_AUTO_LOGIN;
afterEach(() => {
if (originalProfile === undefined) delete process.env.EDGE_EVER_DEVELOPMENT_PROFILE;
else process.env.EDGE_EVER_DEVELOPMENT_PROFILE = originalProfile;
if (originalAutoLogin === undefined) delete process.env.EDGE_EVER_LOCAL_AUTO_LOGIN;
else process.env.EDGE_EVER_LOCAL_AUTO_LOGIN = originalAutoLogin;
mock.restore();
});
const setup = (profile = "local", autoLogin = "true") => {
process.env.EDGE_EVER_DEVELOPMENT_PROFILE = profile;
process.env.EDGE_EVER_LOCAL_AUTO_LOGIN = autoLogin;
const handlers = [];
const provision = mock(async () => ({ token: "test-token", maxAge: 100 }));
const plugin = localDevelopmentAuth(provision);
plugin.configureServer({ middlewares: { use: (handler) => handlers.push(handler) } });
return { handler: handlers[0], provision, plugin };
};
const request = (headers = {}) => ({
url: "/api/v1/auth/session", method: "GET", headers: { host: "127.0.0.1:5173", ...headers },
});
const response = () => ({ setHeader: mock(), end: mock() });
describe("local browser auto-login boundary", () => {
test("is serve-only and absent from remote, demo, plain web and opt-out development", () => {
for (const profile of ["remote", "demo", ""]) expect(setup(profile).handler).toBeUndefined();
expect(setup("local", "false").handler).toBeUndefined();
expect(setup().plugin.apply).toBe("serve");
});
test("returns a validated real session with an HttpOnly cookie for a new browser", async () => {
const { handler, provision } = setup();
const fetchMock = spyOn(globalThis, "fetch")
.mockResolvedValueOnce(Response.json({ authRequired: true, authenticated: false }))
.mockResolvedValueOnce(Response.json({ authRequired: true, authenticated: true, user: { id: "usr_real" } }));
const res = response();
const next = mock();
await handler(request(), res, next);
expect(provision).toHaveBeenCalledTimes(1);
expect(fetchMock.mock.calls[1][1].headers).toEqual({ Authorization: "Bearer test-token" });
expect(res.setHeader).toHaveBeenCalledWith("Set-Cookie", expect.stringContaining("HttpOnly; SameSite=Lax"));
expect(JSON.parse(res.end.mock.calls[0][0]).user.id).toBe("usr_real");
expect(next).not.toHaveBeenCalled();
});
test("does not replace an existing authenticated account", async () => {
const { handler, provision } = setup();
spyOn(globalThis, "fetch").mockResolvedValue(Response.json({ authRequired: true, authenticated: true }));
await handler(request({ cookie: "edgeever_session=existing" }), response(), mock());
expect(provision).not.toHaveBeenCalled();
});
test("does not create sessions for foreign hosts or cross-site requests", async () => {
const { handler, provision } = setup();
const fetchMock = spyOn(globalThis, "fetch");
for (const headers of [{ host: "example.com" }, { origin: "https://example.com" }, { "sec-fetch-site": "cross-site" }]) {
const next = mock();
await handler(request(headers), response(), next);
expect(next).toHaveBeenCalled();
}
expect(fetchMock).not.toHaveBeenCalled();
expect(provision).not.toHaveBeenCalled();
});
test("preserves logout cookies and leaves the login screen accessible afterwards", async () => {
const { handler, provision } = setup();
spyOn(globalThis, "fetch")
.mockResolvedValueOnce(Response.json({ ok: true }, { headers: { "Set-Cookie": "edgeever_session=; Max-Age=0" } }))
.mockResolvedValueOnce(Response.json({ authRequired: true, authenticated: false }));
const res = response();
await handler({ ...request(), url: "/api/v1/auth/logout", method: "POST" }, res, mock());
expect(res.setHeader).toHaveBeenCalledWith("Set-Cookie", [
"edgeever_session=; Max-Age=0", "edgeever_local_logged_out=1; Path=/; HttpOnly; SameSite=Lax",
]);
const next = mock();
await handler(request({ cookie: "edgeever_local_logged_out=1" }), response(), next);
expect(next).toHaveBeenCalled();
expect(provision).not.toHaveBeenCalled();
});
});
+71
View File
@@ -0,0 +1,71 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { Plugin } from "vite";
const execute = promisify(execFile);
const apiUrl = "http://127.0.0.1:8787/api/v1/auth/session";
const createSession = async () => {
const result = await execute("bun", ["scripts/local-dev.mjs", "auth-session", "local"], {
cwd: new URL("../../", import.meta.url),
timeout: 10000,
});
return JSON.parse(result.stdout) as { token: string; maxAge: number };
};
export const localDevelopmentAuth = (provisionSession = createSession): Plugin => ({
name: "edgeever-local-development-auth",
apply: "serve",
configureServer(server) {
// Only the persistent local profile may create sessions in local SQLite.
// Plain dev:web, remote development, demo, and production never do this.
if (process.env.EDGE_EVER_DEVELOPMENT_PROFILE !== "local"
|| process.env.EDGE_EVER_LOCAL_AUTO_LOGIN === "false") return;
server.middlewares.use(async (request, response, next) => {
const path = request.url?.split("?")[0];
const logout = path === "/api/v1/auth/logout" && request.method === "POST";
if (!logout && (path !== "/api/v1/auth/session" || request.method !== "GET")) return next();
const host = request.headers.host ?? "";
if (!/^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(host)) return next();
if (request.headers.origin && request.headers.origin !== `http://${host}`) return next();
if (request.headers["sec-fetch-site"] === "cross-site") return next();
try {
const headers = new Headers();
if (request.headers.cookie) headers.set("Cookie", request.headers.cookie);
if (request.headers.authorization) headers.set("Authorization", request.headers.authorization);
if (logout) {
const upstream = await fetch(apiUrl.replace(/session$/, "logout"), {
method: "POST", headers, signal: AbortSignal.timeout(5000),
});
response.statusCode = upstream.status;
response.setHeader("Set-Cookie", [
...upstream.headers.getSetCookie(),
...(upstream.ok ? ["edgeever_local_logged_out=1; Path=/; HttpOnly; SameSite=Lax"] : []),
]);
response.setHeader("Content-Type", "application/json");
response.setHeader("Cache-Control", "no-store");
response.end(await upstream.text());
return;
}
const current = await fetch(apiUrl, { headers, signal: AbortSignal.timeout(5000) });
if (!current.ok) return next();
const session = await current.json() as { authRequired: boolean; authenticated: boolean };
if (session.authenticated || request.headers.authorization) return next();
// An explicit logout should still let developers test the login screen.
if (request.headers.cookie?.includes("edgeever_local_logged_out=1")) return next();
const { token, maxAge } = await provisionSession();
const authenticated = await fetch(apiUrl, {
headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(5000),
});
if (!authenticated.ok) throw new Error(`Local session check failed: HTTP ${authenticated.status}`);
const body = await authenticated.json() as { authRequired: boolean; authenticated: boolean };
if (!body.authRequired || !body.authenticated) throw new Error("Local API did not accept the development session.");
response.setHeader("Set-Cookie", `edgeever_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`);
response.setHeader("Cache-Control", "no-store");
response.setHeader("Content-Type", "application/json");
response.end(JSON.stringify(body));
} catch (error) {
next(error);
}
});
},
});
+2
View File
@@ -4,6 +4,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath, URL } from "node:url";
import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { localDevelopmentAuth } from "./local-dev-auth";
import { resolveAppVersion, resolveDeploymentMethod, resolveDeploymentTrigger, resolveReleaseTimestamp } from "./build-metadata";
const readPackageVersion = () => {
@@ -147,6 +148,7 @@ export default defineConfig({
__EDGEEVER_DESKTOP_BUILD__: JSON.stringify(isDesktopBuild),
},
plugins: [
localDevelopmentAuth(),
developmentServiceWorkerReset,
react(),
VitePWA({
+41
View File
@@ -0,0 +1,41 @@
import { randomBytes, createHash } from "node:crypto";
import { hashPassword } from "../apps/api/src/auth-crypto.ts";
// Development fixtures only: never imported by either production runtime.
// Reuse the owner of the existing local workspace without changing credentials.
export const createLocalDevelopmentSession = async (database) => {
const passwordHash = await hashPassword("edgeever-local-dev");
return database.transaction(() => {
let user = database.query(`
SELECT u.id, u.username FROM users u
JOIN workspace_members wm ON wm.user_id = u.id
WHERE wm.workspace_id = 'ws_default' AND wm.role = 'owner' AND u.is_disabled = 0
`).get();
if (!user) {
const owner = database.query(`
SELECT user_id FROM workspace_members WHERE workspace_id = 'ws_default' AND role = 'owner'
`).get();
if (owner) throw new Error("The local workspace owner is disabled; refusing to bypass account status.");
user = database.query("SELECT id, username FROM users WHERE is_disabled = 0 ORDER BY created_at, id LIMIT 1").get();
if (!user) {
user = { id: `usr_${crypto.randomUUID()}`, username: "owner" };
database.query("INSERT INTO users (id, username, password_hash, display_name) VALUES (?, ?, ?, ?)")
.run(user.id, user.username, passwordHash, "Local Developer");
}
database.query("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws_default', ?, 'owner')")
.run(user.id);
}
const token = randomBytes(32).toString("base64url");
const now = new Date().toISOString();
const maxAge = 400 * 24 * 60 * 60;
database.query(`
INSERT INTO sessions (id, user_id, token_hash, device_id, user_agent, device_label, expires_at, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
`sess_${crypto.randomUUID()}`, user.id, createHash("sha256").update(token).digest("hex"),
`local-dev_${crypto.randomUUID()}`, "EdgeEver Local Dev", "Local development",
new Date(Date.now() + maxAge * 1000).toISOString(), now, now,
);
return { token, maxAge, username: user.username };
})();
};
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { globSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { createLocalDevelopmentSession } from "./local-dev-auth.mjs";
import { verifyPassword } from "../apps/api/src/auth-crypto.ts";
const databases = [];
const createDatabase = () => {
const db = new Database(":memory:");
databases.push(db);
for (const path of globSync("migrations/*.sql").sort()) db.exec(readFileSync(path, "utf8"));
return db;
};
afterEach(() => { for (const db of databases.splice(0)) db.close(); });
describe("local development real sessions", () => {
test("creates a real owner, workspace membership and hashed session on a fresh database", async () => {
const db = createDatabase();
const session = await createLocalDevelopmentSession(db);
const user = db.query("SELECT * FROM users").get();
expect(session.username).toBe("owner");
expect(await verifyPassword("edgeever-local-dev", user.password_hash)).toBe(true);
expect(db.query("SELECT * FROM workspace_members WHERE user_id = ?").get(user.id)).toMatchObject({
workspace_id: "ws_default", role: "owner",
});
expect(db.query("SELECT * FROM sessions WHERE user_id = ?").get(user.id)).toMatchObject({
token_hash: createHash("sha256").update(session.token).digest("hex"), revoked_at: null,
});
});
test("reuses the existing owner without overwriting credentials, data, or previous sessions", async () => {
const db = createDatabase();
const first = await createLocalDevelopmentSession(db);
db.query("UPDATE users SET username = 'existing-owner', password_hash = 'preserve-me'").run();
const second = await createLocalDevelopmentSession(db);
expect(second.username).toBe("existing-owner");
expect(second.token).not.toBe(first.token);
expect(db.query("SELECT password_hash FROM users").all()).toEqual([{ password_hash: "preserve-me" }]);
expect(db.query("SELECT COUNT(*) AS count FROM sessions WHERE revoked_at IS NULL").get().count).toBe(2);
});
test("does not bypass a disabled owner", async () => {
const db = createDatabase();
await createLocalDevelopmentSession(db);
db.query("UPDATE users SET is_disabled = 1").run();
await expect(createLocalDevelopmentSession(db)).rejects.toThrow("disabled");
expect(db.query("SELECT COUNT(*) AS count FROM users").get().count).toBe(1);
});
});
+25 -6
View File
@@ -2,6 +2,7 @@ import { Database } from "bun:sqlite";
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { resolve, sep } from "node:path";
import { spawnSync } from "node:child_process";
import { createLocalDevelopmentSession } from "./local-dev-auth.mjs";
export const LOCAL_DEV_API_URL = "http://127.0.0.1:8787";
export const LOCAL_DEV_WEB_URL = "http://127.0.0.1:5173";
@@ -199,8 +200,9 @@ const runProfileWeb = (profile) => {
assertChildSucceeded(result);
};
const startProfile = (profile) => {
const startProfile = async (profile) => {
runLocalMigrations(profile);
if (profile.name === "local") await createProfileSession(profile);
console.log(`[local-dev] ${profile.label} migrations ready: ${resolve(profile.statePath)}`);
const result = spawnSync(
process.execPath,
@@ -239,8 +241,21 @@ const waitForLocalApi = async (timeoutMs = 30_000) => {
throw new Error(`Local API did not become healthy within ${timeoutMs / 1_000}s: ${lastError?.message ?? "unknown error"}`);
};
const ensureLocalAiSeed = async () => {
const settingsResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/ai/settings`);
const createProfileSession = async (profile) => {
if (profile.name !== "local") throw new Error("Automatic login is only available for local development.");
const paths = findLocalD1DatabasePaths(profile.statePath);
if (paths.length !== 1) throw new Error("Expected one local database. Run bun run dev:prepare first.");
const database = new Database(paths[0], { readwrite: true });
try {
return await createLocalDevelopmentSession(database);
} finally {
database.close();
}
};
const ensureLocalAiSeed = async (token) => {
const headers = { Authorization: `Bearer ${token}` };
const settingsResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/ai/settings`, { headers });
if (!settingsResponse.ok) {
throw new Error(`Cannot read local AI settings: HTTP ${settingsResponse.status}`);
}
@@ -256,7 +271,7 @@ const ensureLocalAiSeed = async () => {
const createResponse = await fetch(`${LOCAL_DEV_API_URL}/api/v1/ai/providers`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(profile.seed),
});
if (!createResponse.ok) {
@@ -281,6 +296,7 @@ export const buildReadyBanner = (profile, ai = null) => {
`[local-dev] ${profile.label} ready`,
`[local-dev] Web: ${LOCAL_DEV_WEB_URL}`,
`[local-dev] API: ${LOCAL_DEV_API_URL}`,
...(profile.name === "local" ? ["[local-dev] Auth: real local owner session; browser auto-login (EDGE_EVER_LOCAL_AUTO_LOGIN=false to disable)"] : []),
`[local-dev] Data: ${resolve(profile.statePath)} (${profile.demoSeed ? "resettable demo data" : "persistent development data"})`,
...(aiLabel ? [`[local-dev] AI: ${aiLabel}`] : []),
"",
@@ -361,14 +377,17 @@ const main = async () => {
runLocalMigrations(profile);
console.log(`[local-dev] ${profile.label} migrations ready: ${resolve(profile.statePath)}`);
} else if (command === "start") {
startProfile(profile);
await startProfile(profile);
} else if (command === "api") {
runProfileApi(profile);
} else if (command === "web") {
runProfileWeb(profile);
} else if (command === "ready") {
await waitForLocalApi();
printReadyBanner(profile, profile.bootstrapAi ? await ensureLocalAiSeed() : null);
const session = profile.bootstrapAi ? await createProfileSession(profile) : null;
printReadyBanner(profile, session ? await ensureLocalAiSeed(session.token) : null);
} else if (command === "auth-session") {
console.log(JSON.stringify(await createProfileSession(profile)));
} else if (command === "reset") {
if (profile.name !== "demo") throw new Error("Only the demo profile is resettable.");
await resetDemoProfile(profile);
+3 -3
View File
@@ -332,9 +332,9 @@ const runtimeVars = {
EDGE_EVER_R2_BUCKET_NAME: resolvedR2BucketName,
EDGE_EVER_DEMO_MODE: envValue("DEMO_MODE"),
EDGE_EVER_LOCAL_DEMO_SEED: envValue("LOCAL_DEMO_SEED"),
// Auth-free access is a local-development capability. Remote deployments
// fail closed when credentials and users are both missing.
EDGE_EVER_ALLOW_UNAUTHENTICATED: isLocalCommand ? "true" : undefined,
// Persistent local development uses real sessions. Only the isolated local
// demo stays auth-free; remote deployments always fail closed.
EDGE_EVER_ALLOW_UNAUTHENTICATED: isLocalCommand ? String(envValue("LOCAL_DEMO_SEED") === "true") : undefined,
};
const runtimeVarLines = Object.entries(runtimeVars)
.filter(([, value]) => Boolean(value))