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
@@ -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