fix(dev): restore automatic local owner sign-in
This commit is contained in:
1 parent
bae23dc060
commit
ebed41a887
9 files changed
+279
-9
No files matched your search
@@ -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
|
||||
|
||||
@@ -182,6 +182,8 @@ bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
本地开发默认自动登录,首次账号为 `owner` / `edgeever-local-dev`;测试登录页可主动退出登录。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
})();
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user