chore: v2.8.15 — Oh My Pi login sharing, omp --allow-home, Markdown Preview default, first-login password
- omp-share.js: an admin's switch (S3) runs omp's own auth broker + gateway as the host on loopback; a member's omp pane reaches it as a scoped client keyed by its pane token (clients file = live omp panes only), runs on --model agenthub/<model>, never holds a credential. - omp catalog: --allow-home, so a workspace at the account's home keeps its sessions there (they were recorded under a temp dir and dropped). - Settings → Appearance → Open Markdown as: Preview (default) or Raw, for Files/Changes, the config viewer and a chat's file panel. - First-login password change and password rules, avatar URLs, Artifacts "Mine" includes installed shared skills/plugins, a member's own hook pages.
This commit is contained in:
+127
-17
@@ -22,6 +22,15 @@
|
||||
* (`validateProfile`), which its owner edits in Settings → Account without
|
||||
* that step-up; changing their OWN password there needs the old one and
|
||||
* keeps the login it was changed from (`setPassword`'s `keepToken`).
|
||||
* - Two password bars: an admin may GIVE any non-empty password (a create or a
|
||||
* reset — `validatePassword`), and such an account is flagged
|
||||
* `must_change_password`; a password its OWNER picks must pass every rule of
|
||||
* `passwordProblems` (`validateOwnPassword`). While the flag is set, a
|
||||
* cookie login reaches only `/auth/*`, the `/me` reads and
|
||||
* `POST /me/password` (EPWCHANGE) and opens no WebSocket; a pane token is
|
||||
* not held back — its agents were started before the reset.
|
||||
* - Neither the dashboard nor the CLI can leave the install with no enabled
|
||||
* admin (`assertKeepsAdmin`, ELASTADMIN).
|
||||
*
|
||||
* This is login, not isolation: every member still sees every workspace and
|
||||
* session, and every pane runs as the one host account (phases S2/S3 of the
|
||||
@@ -134,6 +143,39 @@ function validatePassword(password) {
|
||||
if (password.length > 1024) throw err("EBADPASSWORD", "password too long");
|
||||
}
|
||||
|
||||
/** Shortest password an account may pick for itself. */
|
||||
const PASSWORD_MIN_LENGTH = 8;
|
||||
|
||||
/**
|
||||
* The rules a password picked by its OWNER fails, as ids (empty = it passes):
|
||||
* `length`, `lower`, `upper`, `digit`, `symbol` and `username` (it contains
|
||||
* the username). The client's checklist (client/src/lib/passwordRules.ts)
|
||||
* mirrors these; this is the one that decides.
|
||||
*/
|
||||
function passwordProblems(password, username) {
|
||||
const p = String(password ?? "");
|
||||
const out = [];
|
||||
if ([...p].length < PASSWORD_MIN_LENGTH) out.push("length");
|
||||
if (!/\p{Ll}/u.test(p)) out.push("lower");
|
||||
if (!/\p{Lu}/u.test(p)) out.push("upper");
|
||||
if (!/\p{N}/u.test(p)) out.push("digit");
|
||||
if (!/[^\p{L}\p{N}]/u.test(p)) out.push("symbol");
|
||||
const name = String(username || "").toLowerCase();
|
||||
if (name && p.toLowerCase().includes(name)) out.push("username");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A password the account picks for itself: every rule (EWEAKPASSWORD). */
|
||||
function validateOwnPassword(password, username) {
|
||||
validatePassword(password);
|
||||
const failed = passwordProblems(password, username);
|
||||
if (failed.length) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", `password too weak: ${failed.join(", ")}`), {
|
||||
rules: failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Users (the CLI's `agenthub users …` and `agenthub server setup`) ────────
|
||||
|
||||
const publicUser = (u) =>
|
||||
@@ -147,6 +189,8 @@ const publicUser = (u) =>
|
||||
// When the avatar was last set (null = none): the client's cache-buster.
|
||||
avatarAt: u.avatar_at ?? null,
|
||||
createdAt: u.created_at ?? null,
|
||||
// An admin gave this password: the account must pick its own first.
|
||||
mustChangePassword: Boolean(u.must_change_password),
|
||||
};
|
||||
|
||||
const MAX_DISPLAY_NAME = 100;
|
||||
@@ -199,7 +243,8 @@ function findUser(username) {
|
||||
return sql("SELECT * FROM users WHERE username = ?").get(String(username || ""));
|
||||
}
|
||||
|
||||
async function createUser(username, password, role = "developer") {
|
||||
/** `mustChange`: an admin gave the password — the user picks their own at first login. */
|
||||
async function createUser(username, password, role = "developer", { mustChange = false } = {}) {
|
||||
const name = validateUsername(username);
|
||||
const linux = require("./linux-accounts");
|
||||
linux.assertLinuxName(name);
|
||||
@@ -210,8 +255,8 @@ async function createUser(username, password, role = "developer") {
|
||||
// S3: the Linux account first — a user who cannot get one is not created.
|
||||
linux.provision("create", name);
|
||||
const { lastInsertRowid } = sql(
|
||||
"INSERT INTO users (username, role, password_hash) VALUES (?, ?, ?)"
|
||||
).run(name, role, hash);
|
||||
"INSERT INTO users (username, role, password_hash, must_change_password) VALUES (?, ?, ?, ?)"
|
||||
).run(name, role, hash, mustChange ? 1 : 0);
|
||||
// S3, opt-in: the admin's plugin baseline, installed AS the new member in
|
||||
// the background (each marketplace is a clone) — the user exists already.
|
||||
require("./member-claude")
|
||||
@@ -227,14 +272,29 @@ function requireUser(username) {
|
||||
}
|
||||
|
||||
/** New password; every login of that user ends — but `keepToken`'s, when
|
||||
* the user changed their own password from that login. */
|
||||
async function setPassword(username, password, { keepToken } = {}) {
|
||||
* the user changed their own password from that login. `mustChange`: an admin
|
||||
* gave it, so the user must pick their own next; without it the flag clears. */
|
||||
async function setPassword(username, password, { keepToken, mustChange = false } = {}) {
|
||||
const u = requireUser(username);
|
||||
validatePassword(password);
|
||||
sql("UPDATE users SET password_hash = ? WHERE id = ?").run(await hashPassword(password), u.id);
|
||||
sql("UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?").run(
|
||||
await hashPassword(password),
|
||||
mustChange ? 1 : 0,
|
||||
u.id
|
||||
);
|
||||
endSessions(u.id, keepToken);
|
||||
}
|
||||
|
||||
/** EWEAKPASSWORD (`reused`) when `password` is the account's current one. */
|
||||
async function assertNotCurrent(userId, password) {
|
||||
const row = sql("SELECT password_hash FROM users WHERE id = ?").get(userId);
|
||||
if (row && (await verifyPassword(String(password ?? ""), row.password_hash))) {
|
||||
throw Object.assign(err("EWEAKPASSWORD", "the new password is the current one"), {
|
||||
rules: ["reused"],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** End every login of `userId` but `exceptToken`'s; returns how many ended. */
|
||||
function endSessions(userId, exceptToken) {
|
||||
if (exceptToken) {
|
||||
@@ -246,18 +306,52 @@ function endSessions(userId, exceptToken) {
|
||||
return sql("DELETE FROM auth_sessions WHERE user_id = ?").run(userId).changes;
|
||||
}
|
||||
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and the newest one's start. */
|
||||
/** `{ logins, lastLoginAt }`: the user's unexpired logins and their last login
|
||||
* ever (`users.last_login_at`; the newest login's start for one made before it). */
|
||||
function loginStats(userId) {
|
||||
const r = sql(
|
||||
`SELECT COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE user_id = ? AND expires_at > ?`
|
||||
).get(userId, new Date().toISOString());
|
||||
return { logins: r.n, lastLoginAt: r.last || null };
|
||||
const at = sql("SELECT last_login_at FROM users WHERE id = ?").get(userId)?.last_login_at;
|
||||
return { logins: r.n, lastLoginAt: at || r.last || null };
|
||||
}
|
||||
|
||||
/** `loginStats` for every account in two queries: Map(user id → stats). */
|
||||
function allLoginStats() {
|
||||
const now = new Date().toISOString();
|
||||
const live = new Map(
|
||||
sql(
|
||||
`SELECT user_id, COUNT(*) AS n, MAX(created_at) AS last FROM auth_sessions
|
||||
WHERE expires_at > ? GROUP BY user_id`
|
||||
)
|
||||
.all(now)
|
||||
.map((r) => [r.user_id, r])
|
||||
);
|
||||
const out = new Map();
|
||||
for (const u of sql("SELECT id, last_login_at FROM users").all()) {
|
||||
const r = live.get(u.id);
|
||||
out.set(u.id, { logins: r?.n || 0, lastLoginAt: u.last_login_at || r?.last || null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* ELASTADMIN when `u` is the only enabled admin and the change would leave it
|
||||
* no longer one — the CLI included, which has no "yourself" to refuse.
|
||||
*/
|
||||
function assertKeepsAdmin(u, what) {
|
||||
if (u.role !== "admin" || u.disabled) return;
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", `the last admin cannot be ${what}`);
|
||||
}
|
||||
|
||||
function setRole(username, role) {
|
||||
if (!ROLES.includes(role)) throw err("EBADROLE", `role: one of ${ROLES.join(", ")}`);
|
||||
const u = requireUser(username);
|
||||
if (role !== "admin") assertKeepsAdmin(u, "demoted");
|
||||
sql("UPDATE users SET role = ? WHERE id = ?").run(role, u.id);
|
||||
if (role !== "admin") stopHostPanes(u.id);
|
||||
}
|
||||
@@ -287,6 +381,7 @@ function stopHostPanes(userId) {
|
||||
* `deleteUser` below is the one that removes an account for good. */
|
||||
function setDisabled(username, disabled) {
|
||||
const u = requireUser(username);
|
||||
if (disabled) assertKeepsAdmin(u, "disabled");
|
||||
const linux = require("./linux-accounts");
|
||||
// S3: lock (and end the processes of) the Linux account too — when there is
|
||||
// one: a user made before S3, or whose name fits no Linux login, has none,
|
||||
@@ -316,12 +411,7 @@ function setDisabled(username, disabled) {
|
||||
*/
|
||||
function deleteUser(username) {
|
||||
const u = requireUser(username);
|
||||
if (u.role === "admin") {
|
||||
const others = sql(
|
||||
"SELECT COUNT(*) AS n FROM users WHERE role = 'admin' AND disabled = 0 AND id != ?"
|
||||
).get(u.id).n;
|
||||
if (!others) throw err("ELASTADMIN", "the last admin cannot be deleted");
|
||||
}
|
||||
assertKeepsAdmin(u, "deleted");
|
||||
const lanes = sql("SELECT id, cwd FROM lanes WHERE owner_id = ?").all(u.id);
|
||||
sql("DELETE FROM auth_sessions WHERE user_id = ?").run(u.id);
|
||||
const pty = require("./pty-run");
|
||||
@@ -514,6 +604,7 @@ async function login(req, username, password) {
|
||||
if (!u || !ok || u.disabled) throw err("EBADLOGIN", "wrong username or password");
|
||||
failures.delete(ipKey);
|
||||
failures.delete(userKey);
|
||||
sql("UPDATE users SET last_login_at = ? WHERE id = ?").run(new Date().toISOString(), u.id);
|
||||
return { user: publicUser(u), token: startSession(u.id) };
|
||||
}
|
||||
|
||||
@@ -598,6 +689,12 @@ const underPrefix = (p, x) => {
|
||||
|
||||
const deny = (res, status, code, message) => res.status(status).json({ error: { code, message } });
|
||||
|
||||
/** What a login flagged `must_change_password` may still call. */
|
||||
function mayBeforePasswordChange(method, lp) {
|
||||
if (method === "GET" || method === "HEAD") return lp === "/me" || lp.startsWith("/me/");
|
||||
return method === "POST" && lp === "/me/password";
|
||||
}
|
||||
|
||||
/** Express middleware for `/api`; a no-op in local mode. */
|
||||
function authGuard(req, res, next) {
|
||||
if (!isServerMode()) return next();
|
||||
@@ -610,9 +707,15 @@ function authGuard(req, res, next) {
|
||||
? next()
|
||||
: deny(res, 403, "ELOCALONLY", "hooks are accepted from this machine only");
|
||||
}
|
||||
const user = userFromRequest(req) || userFromPaneToken(req);
|
||||
const cookieUser = userFromRequest(req);
|
||||
const user = cookieUser || userFromPaneToken(req);
|
||||
if (!user) return deny(res, 401, "EAUTH", "log in first");
|
||||
req.user = user;
|
||||
// A password an admin gave: the login may only read its own account and
|
||||
// pick its own password until it has.
|
||||
if (cookieUser?.mustChangePassword && !mayBeforePasswordChange(req.method, lp)) {
|
||||
return deny(res, 403, "EPWCHANGE", "choose your own password first");
|
||||
}
|
||||
const under = (x) => underPrefix(p, x);
|
||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||
if (
|
||||
@@ -655,8 +758,10 @@ function isWebSocketLoggedIn(req) {
|
||||
}
|
||||
if (host !== String(req.headers.host || "").toLowerCase() || !isHostAllowed(host)) return false;
|
||||
}
|
||||
// Which terminals a login may open is the run check's (websocket.js).
|
||||
return Boolean(userFromRequest(req));
|
||||
// Which terminals a login may open is the run check's (websocket.js). None
|
||||
// until a password an admin gave is replaced.
|
||||
const user = userFromRequest(req);
|
||||
return Boolean(user && !user.mustChangePassword);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -684,8 +789,13 @@ module.exports = {
|
||||
validateProfile,
|
||||
setProfile,
|
||||
validatePassword,
|
||||
validateOwnPassword,
|
||||
passwordProblems,
|
||||
assertNotCurrent,
|
||||
PASSWORD_MIN_LENGTH,
|
||||
endSessions,
|
||||
loginStats,
|
||||
allLoginStats,
|
||||
hasAdmin,
|
||||
userFromRequest,
|
||||
login,
|
||||
|
||||
Reference in New Issue
Block a user