feat(desktop): Electron shell M2 — embedded server, desktop login, instance lock (#173)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-04 16:57:28 +08:00
committed by GitHub
parent 045ac250e0
commit 7015fb0153
35 changed files with 1477 additions and 112 deletions
+7 -4
View File
@@ -11,7 +11,7 @@ import type { MiddlewareHandler } from "hono";
import { getCookie } from "hono/cookie";
import { HttpError } from "../http/errors.js";
import type { UserRow } from "../db/repos/users.js";
import type { AuthService } from "./service.js";
import type { AuthService, SessionVia } from "./service.js";
/** Session cookie name. */
export const SESSION_COOKIE = "penguin_session";
@@ -20,6 +20,8 @@ export const SESSION_COOKIE = "penguin_session";
export type AppEnv = {
Variables: {
user: UserRow;
/** How the current session was established ("password" | "desktop"); legacy rows read as "password". */
sessionVia: SessionVia;
};
};
@@ -31,11 +33,12 @@ export function currentUser(c: { var: { user: UserRow } }): UserRow {
export function authMiddleware(auth: AuthService): MiddlewareHandler<AppEnv> {
return async (c, next) => {
const token = getCookie(c, SESSION_COOKIE);
const user = token ? auth.authenticate(token) : null;
if (!user) {
const authed = token ? auth.authenticateWithMeta(token) : null;
if (!authed) {
throw new HttpError(401, "unauthorized", "Not signed in or the sign-in has expired.");
}
c.set("user", user);
c.set("user", authed.user);
c.set("sessionVia", authed.via);
await next();
};
}
+43 -4
View File
@@ -57,6 +57,14 @@ function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
/**
* How a session was established: "password" via the login form, "desktop" via the
* desktop shell's one-shot token (see design § "桌面端原型 · 桌面登录"). Persisted per
* session so desktop-specific allowances (password change without the old password)
* apply only to sessions the shell itself opened. Legacy rows (NULL) read as "password".
*/
export type SessionVia = "password" | "desktop";
export function toUserInfo(row: UserRow): UserInfo {
return {
userId: row.userId,
@@ -159,7 +167,22 @@ export class AuthService {
}
this.loginFailures.delete(userId);
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId) };
return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
}
/**
* Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
* (the desktop-login route) has already redeemed the shell's one-shot token, which is
* the credential here. Throws if the admin has not been seeded yet (desktop-login runs
* after startup seeding, so this only trips on a broken deployment).
*/
loginDesktop(): { user: UserInfo; token: string } {
const row = this.deps.users.findById(ADMIN_USER_ID);
if (!row) {
throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
}
this.deps.authSessions.deleteExpired(this.now().toISOString());
return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
}
/** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
@@ -174,12 +197,25 @@ export class AuthService {
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
/**
* Desktop-session password set: no old-password check. Only reachable for sessions
* established via desktop-login (the me route gates on sessionVia) — the seed password
* of a desktop-created root is random and never shown, so its holder has nothing to
* type into an old-password field; the shell's token already proved machine ownership.
*/
async setPasswordDesktop(userId: string, newPassword: string): Promise<void> {
if (newPassword.length < MIN_PASSWORD_LENGTH) {
throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
}
this.deps.users.updatePassword(userId, await hashPassword(newPassword), false);
}
logout(token: string): void {
this.deps.authSessions.delete(sha256Hex(token));
}
/** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
authenticate(token: string): UserRow | null {
authenticateWithMeta(token: string): { user: UserRow; via: SessionVia } | null {
const tokenHash = sha256Hex(token);
const session = this.deps.authSessions.findByTokenHash(tokenHash);
if (!session) return null;
@@ -195,10 +231,12 @@ export class AuthService {
new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
);
}
return this.deps.users.findById(session.userId);
const user = this.deps.users.findById(session.userId);
if (!user) return null;
return { user, via: session.via === "desktop" ? "desktop" : "password" };
}
private issueSession(userId: string): string {
private issueSession(userId: string, via: SessionVia): string {
const token = randomBytes(32).toString("base64url");
const now = this.now();
this.deps.authSessions.insert({
@@ -206,6 +244,7 @@ export class AuthService {
userId,
createdAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
via,
});
return token;
}