From ebed41a887e397f2298bddfb94d3d7aef39b28e4 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Thu, 3 Sep 2026 19:29:58 +0800 Subject: [PATCH] fix(dev): restore automatic local owner sign-in --- README.md | 2 + README.zh-CN.md | 2 + apps/web/local-dev-auth.test.mjs | 83 ++++++++++++++++++++++++++++++++ apps/web/local-dev-auth.ts | 71 +++++++++++++++++++++++++++ apps/web/vite.config.ts | 2 + scripts/local-dev-auth.mjs | 41 ++++++++++++++++ scripts/local-dev-auth.test.mjs | 50 +++++++++++++++++++ scripts/local-dev.mjs | 31 +++++++++--- scripts/run-wrangler.mjs | 6 +-- 9 files changed, 279 insertions(+), 9 deletions(-) create mode 100644 apps/web/local-dev-auth.test.mjs create mode 100644 apps/web/local-dev-auth.ts create mode 100644 scripts/local-dev-auth.mjs create mode 100644 scripts/local-dev-auth.test.mjs diff --git a/README.md b/README.md index 8562054b..1cade678 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 7d11bce6..eb0d1bb6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -182,6 +182,8 @@ bun install bun run dev ``` +本地开发默认自动登录,首次账号为 `owner` / `edgeever-local-dev`;测试登录页可主动退出登录。 + ## 目录结构 ```text diff --git a/apps/web/local-dev-auth.test.mjs b/apps/web/local-dev-auth.test.mjs new file mode 100644 index 00000000..8e6b22b2 --- /dev/null +++ b/apps/web/local-dev-auth.test.mjs @@ -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(); + }); +}); diff --git a/apps/web/local-dev-auth.ts b/apps/web/local-dev-auth.ts new file mode 100644 index 00000000..d0638f16 --- /dev/null +++ b/apps/web/local-dev-auth.ts @@ -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); + } + }); + }, +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 30355762..7873c5e9 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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({ diff --git a/scripts/local-dev-auth.mjs b/scripts/local-dev-auth.mjs new file mode 100644 index 00000000..2cac575c --- /dev/null +++ b/scripts/local-dev-auth.mjs @@ -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 }; + })(); +}; diff --git a/scripts/local-dev-auth.test.mjs b/scripts/local-dev-auth.test.mjs new file mode 100644 index 00000000..fa0200e4 --- /dev/null +++ b/scripts/local-dev-auth.test.mjs @@ -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); + }); +}); diff --git a/scripts/local-dev.mjs b/scripts/local-dev.mjs index 1f2dde5e..192a8005 100644 --- a/scripts/local-dev.mjs +++ b/scripts/local-dev.mjs @@ -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); diff --git a/scripts/run-wrangler.mjs b/scripts/run-wrangler.mjs index fc7515e1..ce4f3af5 100644 --- a/scripts/run-wrangler.mjs +++ b/scripts/run-wrangler.mjs @@ -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))