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

+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))