Files
pikasTech-HWLAB/internal/cloud/access-control.ts
T
2026-06-06 11:40:21 +08:00

2688 lines
155 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createHash, createPublicKey, randomBytes, randomUUID, verify as verifySignature } from "node:crypto";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
import {
codeAgentAgentRunAdapterEnabled,
loadPersistedAgentRunResult,
syncAgentRunChatResult
} from "./code-agent-agentrun-adapter.ts";
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
import {
HWLAB_TOOL_IDS,
createOpenFgaAuthorizer,
openFgaObject
} from "./openfga-authorization.ts";
import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts";
const SESSION_COOKIE = "hwlab_session";
const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24;
const SESSION_COOKIE_SAMESITE = "Lax";
function buildSessionCookie(token, maxAge, { secure = false } = {}) {
return `${SESSION_COOKIE}=${encodeURIComponent(token)}; ${sessionCookieAttributes(maxAge, { secure })}`;
}
function buildClearSessionCookie({ secure = false } = {}) {
return `${SESSION_COOKIE}=; ${sessionCookieAttributes(0, { secure })}`;
}
function sessionCookieAttributes(maxAge, { secure = false } = {}) {
return ["Path=/", "HttpOnly", secure ? "Secure" : "", `SameSite=${SESSION_COOKIE_SAMESITE}`, `Max-Age=${maxAge}`]
.filter(Boolean)
.join("; ");
}
const API_KEY_PREFIX = "hwl_live_";
const API_KEY_RANDOM_BYTES = 32;
const API_KEY_DEFAULT_NAME = "Default API key";
const ADMIN_BOOTSTRAP_API_KEY_NAME = "Master server admin API key";
const AUTH_METHOD_API_KEY = "api-key";
const AUTH_METHOD_WEB_SESSION = "web-session";
const AUTH_METHOD_LEGACY_LOCAL_SESSION = "legacy-local-session";
const ACCESS_SCHEMA_STATEMENTS = Object.freeze([
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL CHECK (role IN ('admin', 'user')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
password_hash TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS auth_provider TEXT NOT NULL DEFAULT 'local'`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS keycloak_issuer TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS keycloak_sub TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS email TEXT`,
`ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TEXT`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_users_keycloak_subject ON users(keycloak_issuer, keycloak_sub) WHERE keycloak_issuer IS NOT NULL AND keycloak_sub IS NOT NULL`,
`CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT 'Default API key',
key_prefix TEXT NOT NULL,
key_hash TEXT,
display_secret TEXT,
scopes_json TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id, status, created_at DESC)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_prefix ON api_keys(key_prefix)`,
`CREATE TABLE IF NOT EXISTS oidc_states (
state TEXT PRIMARY KEY,
nonce TEXT NOT NULL,
return_to TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_oidc_states_expires ON oidc_states(expires_at)`,
`CREATE TABLE IF NOT EXISTS user_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
session_token_hash TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
)`,
`CREATE TABLE IF NOT EXISTS agent_sessions (
id TEXT PRIMARY KEY,
project_id TEXT,
agent_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
started_at TEXT,
ended_at TEXT,
owner_user_id TEXT REFERENCES users(id),
conversation_id TEXT,
thread_id TEXT,
last_trace_id TEXT,
session_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT
)`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id)`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS conversation_id TEXT`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS thread_id TEXT`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS last_trace_id TEXT`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS session_json TEXT NOT NULL DEFAULT '{}'`,
`ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS updated_at TEXT`,
`CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id)`,
`CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id)`,
`CREATE INDEX IF NOT EXISTS idx_agent_sessions_last_trace ON agent_sessions(last_trace_id)`,
`CREATE TABLE IF NOT EXISTS account_workspaces (
id TEXT PRIMARY KEY,
owner_user_id TEXT NOT NULL REFERENCES users(id),
project_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT 'Default Workbench',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
is_default BOOLEAN NOT NULL DEFAULT true,
selected_conversation_id TEXT,
selected_agent_session_id TEXT,
active_trace_id TEXT,
provider_profile TEXT,
workspace_json TEXT NOT NULL DEFAULT '{}',
revision INTEGER NOT NULL DEFAULT 1,
updated_by_session_id TEXT,
updated_by_client TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_account_workspaces_default ON account_workspaces(owner_user_id, project_id) WHERE is_default = TRUE AND status = 'active'`,
`CREATE INDEX IF NOT EXISTS idx_account_workspaces_owner ON account_workspaces(owner_user_id, project_id, updated_at DESC)`,
`CREATE TABLE IF NOT EXISTS access_config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS access_tuples (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
relation TEXT NOT NULL,
object TEXT NOT NULL,
created_by_admin_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, relation, object)
)`,
`CREATE INDEX IF NOT EXISTS idx_access_tuples_user ON access_tuples(user_id, object)`,
`CREATE INDEX IF NOT EXISTS idx_access_tuples_object ON access_tuples(object, relation)`
]);
export function createAccessController(options = {}) {
return new AccessController({
...options,
store: options.store ?? accessStoreForRuntime(options.runtimeStore, options)
});
}
function accessStoreForRuntime(runtimeStore, options = {}) {
if (runtimeStore && typeof runtimeStore.query === "function") {
return new PostgresAccessStore({ query: runtimeStore.query.bind(runtimeStore), now: options.now });
}
return new MemoryAccessStore({ now: options.now });
}
class AccessController {
constructor({ store, env = process.env, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) {
this.store = store;
this.env = env;
this.fetchImpl = fetchImpl;
this.traceStore = traceStore;
this.codeAgentChatResults = codeAgentChatResults;
this.codeAgentEnv = env;
this.now = now;
this.required = required;
this.openFga = openFgaAuthorizer ?? createOpenFgaAuthorizer({ env, fetchImpl, now, configStore: store });
this.bootstrapAttempted = false;
}
configureCodeAgentWorkspaceContext({ env = null, fetchImpl = null, traceStore = null, codeAgentChatResults = null } = {}) {
if (env) this.codeAgentEnv = env;
if (fetchImpl) this.fetchImpl = fetchImpl;
if (traceStore) this.traceStore = traceStore;
if (codeAgentChatResults) this.codeAgentChatResults = codeAgentChatResults;
}
async handleAuthRoute(request, response, url) {
try {
await this.ensureBootstrap();
if (url.pathname === "/auth/oidc/login" && (request.method === "GET" || request.method === "POST")) {
await this.handleOidcLogin(request, response, url);
return;
}
if (url.pathname === "/auth/oidc/callback" && request.method === "GET") {
await this.handleOidcCallback(request, response, url);
return;
}
if (request.method === "GET" && url.pathname === "/auth/session") {
sendJson(response, 200, await this.sessionPayload(request));
return;
}
if (request.method === "POST" && url.pathname === "/auth/login") {
await this.handleLogin(request, response);
return;
}
if (request.method === "POST" && url.pathname === "/auth/logout") {
await this.handleLogout(request, response);
return;
}
sendJson(response, 404, errorPayload("not_found", "Auth route is not implemented", 404));
} catch (error) {
sendAccessError(response, error);
}
}
async handleUserRoute(request, response, url) {
try {
await this.ensureBootstrap();
if (request.method === "GET" && url.pathname === "/v1/users/me") {
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
return sendJson(response, 200, {
authenticated: true,
actor: publicActor(auth.actor),
session: auth.session,
authMethod: publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)),
contractVersion: "user-access-v1"
});
}
if (url.pathname === "/v1/api-keys" || url.pathname === "/v1/api-keys/default" || url.pathname.startsWith("/v1/api-keys/")) {
return this.handleApiKeyRoute(request, response, url);
}
sendJson(response, 404, errorPayload("not_found", "User route is not implemented", 404));
} catch (error) {
sendAccessError(response, error);
}
}
async handleApiKeyRoute(request, response, url) {
try {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
const path = url.pathname;
if (request.method === "GET" && path === "/v1/api-keys") {
const keys = await this.store.listApiKeysForUser?.(auth.actor.id) ?? [];
return sendJson(response, 200, { contractVersion: "user-api-key-v1", keys: keys.map(publicApiKey), count: keys.length });
}
if (request.method === "GET" && path === "/v1/api-keys/default") {
const existing = await this.store.findActiveDefaultApiKeyForUser?.(auth.actor.id) ?? null;
if (existing) return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(existing), created: false });
const issued = await this.store.ensureDefaultApiKeyForUser?.({ userId: auth.actor.id, now: this.now() }) ?? null;
if (!issued?.key) return sendJson(response, 500, errorPayload("api_key_issue_failed", "Failed to issue default API key", 500));
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(issued.key, { includeSecret: true }), created: true });
}
if (request.method === "POST" && path === "/v1/api-keys") {
const body = await jsonBody(request);
const name = textOr(body.name, "").trim() || "User API key";
const secret = generateApiKey();
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const created = await this.store.createApiKey?.({ userId: auth.actor.id, name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: this.now() });
if (!created) return sendJson(response, 500, errorPayload("api_key_create_failed", "Failed to create API key", 500));
return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(created, { includeSecret: true }), created: true });
}
const regenerateMatch = path.match(/^\/v1\/api-keys\/([^/]+)\/regenerate$/u);
if (request.method === "POST" && regenerateMatch) {
const keyId = decodeURIComponent(regenerateMatch[1]);
const existing = await this.store.findApiKeyById?.(keyId) ?? null;
if (!existing || existing.userId !== auth.actor.id) return sendJson(response, 404, errorPayload("api_key_not_found", "API key was not found", 404));
await this.store.revokeApiKey?.(keyId, this.now());
const secret = generateApiKey();
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const fresh = await this.store.createApiKey?.({ userId: auth.actor.id, name: existing.name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: existing.scopes, now: this.now() });
return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(fresh, { includeSecret: true }), regenerated: true, oldKeyId: keyId });
}
const deleteMatch = path.match(/^\/v1\/api-keys\/([^/]+)$/u);
if (request.method === "DELETE" && deleteMatch) {
const keyId = decodeURIComponent(deleteMatch[1]);
const existing = await this.store.findApiKeyById?.(keyId) ?? null;
if (!existing || existing.userId !== auth.actor.id) return sendJson(response, 404, errorPayload("api_key_not_found", "API key was not found", 404));
const revoked = await this.store.revokeApiKey?.(keyId, this.now());
return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(revoked), revoked: true });
}
sendJson(response, 404, errorPayload("not_found", "API key route is not implemented", 404));
} catch (error) {
sendAccessError(response, error);
}
}
async handleAccessStatusRoute(request, response, url) {
try {
await this.ensureBootstrap();
if (request.method === "GET" && url.pathname === "/v1/access/status") {
return sendJson(response, 200, await this.accessStatusPayload(request));
}
if (request.method === "GET" && url.pathname === "/v1/setup/status") {
return sendJson(response, 200, await this.setupStatusPayload());
}
sendJson(response, 404, errorPayload("not_found", "Access status route is not implemented", 404));
} catch (error) {
sendAccessError(response, error);
}
}
async handleSetupRoute(request, response, url) {
try {
await this.ensureBootstrap();
if (request.method !== "POST" || url.pathname !== "/v1/setup/first-admin") {
return sendJson(response, 404, errorPayload("not_found", "Setup route is not implemented", 404));
}
if (!(await this.setupRequired())) {
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
}
const body = await jsonBody(request);
const password = requiredText(body.password, "password");
const now = this.now();
const user = await this.store.createFirstAdmin?.({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: textOr(body.username, this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin"),
displayName: textOr(body.displayName, this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin"),
passwordHash: hashPassword(password),
now
}) ?? null;
if (!user) {
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
}
const token = randomBytes(32).toString("base64url");
const session = await this.store.createSession({
userId: user.id,
tokenHash: sha256(token),
now,
expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString()
});
await this.store.ensureDefaultApiKeyForUser?.({ userId: user.id, now }) ?? null;
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
return sendJson(response, 201, {
created: true,
authenticated: true,
actor: publicActor(user),
session: publicSession({ ...session, user }),
setupRequired: false,
contractVersion: "user-access-v1"
});
} catch (error) {
sendAccessError(response, error);
}
}
async sessionPayload(request) {
const auth = await this.authenticate(request, { required: false });
return {
authenticated: Boolean(auth.actor),
actor: auth.actor ? publicActor(auth.actor) : null,
session: auth.session ?? null,
authMethod: auth.actor ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null,
sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null,
sessionTtlSeconds: SESSION_MAX_AGE_SECONDS,
setupRequired: await this.setupRequired(),
contractVersion: "user-access-v1"
};
}
async accessStatusPayload(request) {
const session = await this.sessionPayload(request);
return {
serviceId: CLOUD_API_SERVICE_ID,
...session,
accessControlRequired: this.required,
roles: ["admin", "user"],
routes: accessRoutes()
};
}
async setupStatusPayload() {
return {
serviceId: CLOUD_API_SERVICE_ID,
contractVersion: "user-access-v1",
accessControlRequired: this.required,
setupRequired: await this.setupRequired(),
bootstrapAdminConfigured: Boolean(textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH, "") || textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD, "")),
routes: accessRoutes()
};
}
async handleAdminRoute(request, response, url) {
try {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (auth.actor.role !== "admin") {
return sendJson(response, 403, errorPayload("admin_required", "Only admin users can call v0.2 admin APIs", 403));
}
if (url.pathname === "/v1/admin/access" || url.pathname.startsWith("/v1/admin/access/")) {
return this.handleAdminAccessRoute(request, response, url, auth);
}
if (request.method === "POST" && url.pathname === "/v1/admin/users") {
const body = await jsonBody(request);
const user = await this.store.createUser({
username: requiredText(body.username, "username"),
displayName: textOr(body.displayName, body.username),
role: body.role === "admin" ? "admin" : "user",
status: body.status === "disabled" ? "disabled" : "active",
passwordHash: hashPassword(requiredText(body.password, "password")),
now: this.now()
});
await this.syncUserAdminTuple(user, auth.actor);
return sendJson(response, 201, { created: true, user: redactedUser(user) });
}
sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404));
} catch (error) {
sendAccessError(response, error);
}
}
async handleAdminAccessRoute(request, response, url, auth) {
if (request.method === "GET" && url.pathname === "/v1/admin/access/summary") {
const payload = await this.adminAccessSummary(auth.actor);
return sendJson(response, 200, payload);
}
if (request.method === "GET" && url.pathname === "/v1/admin/access/users") {
const users = await this.store.listUsers?.() ?? [];
const summaries = [];
for (const user of users) summaries.push(await this.userAccessSummary(user));
return sendJson(response, 200, {
contractVersion: "admin-access-v1",
actor: publicActor(auth.actor),
openfga: await this.openFga.describe(),
users: summaries,
count: summaries.length
});
}
const userMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)$/u);
if (request.method === "GET" && userMatch) {
const user = await this.store.getUserById(decodeURIComponent(userMatch[1]));
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
return sendJson(response, 200, await this.userAccessMatrixPayload(user, auth.actor));
}
if ((request.method === "PATCH" || request.method === "PUT") && userMatch) {
const userId = decodeURIComponent(userMatch[1]);
const body = await jsonBody(request);
const existing = await this.store.getUserById(userId);
if (!existing) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
const updated = await this.store.updateUserAccess?.({
userId,
role: body.role === "admin" ? "admin" : body.role === "user" ? "user" : existing.role,
status: body.status === "disabled" ? "disabled" : body.status === "active" ? "active" : existing.status,
now: this.now()
}) ?? existing;
await this.syncUserAdminTuple(updated, auth.actor);
return sendJson(response, 200, { updated: true, user: redactedUser(updated), access: await this.userAccessMatrixPayload(updated, auth.actor) });
}
const toolMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/tools\/([^/]+)\/can-use$/u);
if (toolMatch && (request.method === "PUT" || request.method === "DELETE")) {
const userId = decodeURIComponent(toolMatch[1]);
const toolId = normalizeToolId(decodeURIComponent(toolMatch[2]));
if (!HWLAB_TOOL_IDS.includes(toolId)) return sendJson(response, 400, errorPayload("invalid_tool_id", "Tool id is not supported", 400));
const user = await this.store.getUserById(userId);
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
const object = openFgaObject("tool", toolId);
const result = request.method === "PUT"
? await this.grantAccessTuple({ userId, relation: "can_use", object, admin: auth.actor })
: await this.revokeAccessTuple({ userId, relation: "can_use", object });
if (result.ok === false) return sendJson(response, result.status ?? 503, accessWriteErrorPayload(result));
return sendJson(response, request.method === "PUT" ? 201 : 200, { ok: result.ok !== false, changed: true, result, access: await this.userAccessMatrixPayload(user, auth.actor) });
}
if (request.method === "POST" && url.pathname === "/v1/admin/access/check") {
const body = await jsonBody(request);
const userId = requiredText(body.userId ?? body.user ?? body.actorId, "userId");
const relation = requiredText(body.relation, "relation");
const object = requiredText(body.object, "object");
const user = await this.store.getUserById(userId);
if (!user) return sendJson(response, 404, errorPayload("user_not_found", "User was not found", 404));
const authorization = await this.openFga.check({ actor: user, relation, object });
return sendJson(response, 200, { contractVersion: "admin-access-v1", actor: publicActor(auth.actor), subject: publicActor(user), authorization });
}
return sendJson(response, 404, errorPayload("not_found", "Admin Access route is not implemented", 404));
}
async adminAccessSummary(actor) {
const [users, tuples, openfga] = await Promise.all([
this.store.listUsers?.() ?? [],
this.store.listAccessTuples?.() ?? [],
this.openFga.describe()
]);
const toolTupleCount = tuples.filter((tuple) => tuple.object === openFgaObject("tool", "hwpod") || tuple.object.startsWith("tool:")).length;
return {
contractVersion: "admin-access-v1",
actor: publicActor(actor),
openfga,
counts: {
users: users.length,
tuples: tuples.length,
toolTuples: toolTupleCount
},
supported: {
toolIds: HWLAB_TOOL_IDS
}
};
}
async userAccessSummary(user) {
const tuples = await this.store.listAccessTuples?.({ userId: user.id }) ?? [];
return {
user: redactedUser(user),
tupleCount: tuples.length,
tools: await this.effectiveToolMatrix(user, tuples)
};
}
async userAccessMatrixPayload(user, actor) {
const [tuples, openfga] = await Promise.all([
this.store.listAccessTuples?.({ userId: user.id }) ?? [],
this.openFga.describe()
]);
const tools = await this.effectiveToolMatrix(user, tuples);
return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, tools, tuples: tuples.map(publicAccessTuple) };
}
async effectiveToolMatrix(user, tuples = []) {
const explicitTools = toolsFromTuples(tuples);
const entries = {};
for (const toolId of HWLAB_TOOL_IDS) {
if (explicitTools[toolId]) {
entries[toolId] = true;
continue;
}
const authorization = await this.openFga.check({ actor: user, relation: "can_use", object: openFgaObject("tool", toolId) });
entries[toolId] = authorization.allowed === true;
}
return entries;
}
async syncUserAdminTuple(user, admin) {
if (!user) return null;
const object = openFgaObject("system", "hwlab");
return user.role === "admin"
? this.grantAccessTuple({ userId: user.id, relation: "admin", object, admin: admin ?? user })
: this.revokeAccessTuple({ userId: user.id, relation: "admin", object });
}
async syncExistingAdminTuples() {
const users = await this.store.listUsers?.() ?? [];
for (const user of users.filter((item) => item.role === "admin")) await this.syncUserAdminTuple(user, user);
}
async grantAccessTuple({ userId, relation, object, admin }) {
const result = await this.openFga.writeTuple({ userId, relation, object });
if (result.ok !== false) await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() });
return result;
}
async revokeAccessTuple({ userId, relation, object }) {
const result = await this.openFga.deleteTuple({ userId, relation, object });
if (result.ok !== false) await this.store.deleteAccessTuple?.({ userId, relation, object });
return result;
}
async authenticate(request, { required = this.required } = {}) {
await this.ensureBootstrap();
const apiKeySecret = apiKeyFromRequest(request);
if (apiKeySecret) return this.authenticateApiKey(apiKeySecret);
const token = sessionCookieFromRequest(request);
if (!token) {
return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null };
}
const session = await this.store.findSessionByTokenHash(sha256(token), this.now());
if (!session?.user || session.user.status !== "active") {
return errorPayload("auth_session_invalid", "Session is missing, expired, revoked, or disabled", 401);
}
await this.store.touchSession(session.id, this.now());
return { ok: true, actor: session.user, session: publicSession(session), authMethod: AUTH_METHOD_WEB_SESSION };
}
async authenticateApiKey(secret) {
if (!isApiKeySecret(secret)) return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const stored = await this.store.findApiKeyByHash(hash);
if (!stored || stored.keyPrefix !== prefix) return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
const user = await this.store.getUserById(stored.userId);
if (!user || user.status !== "active") return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
await this.store.touchApiKey(stored.id, this.now());
return {
ok: true,
actor: user,
session: { id: stored.id, userId: user.id, keyPrefix: stored.keyPrefix, name: stored.name, createdAt: stored.createdAt, lastSeenAt: this.now(), expiresAt: null, revoked: false, tokenSource: "api-key", valuesRedacted: true },
apiKey: { id: stored.id, keyPrefix: stored.keyPrefix, name: stored.name },
authMethod: AUTH_METHOD_API_KEY
};
}
async handleOidcLogin(request, response, url) {
const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, "");
const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, "");
if (!issuer || !clientId) {
return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503));
}
const returnTo = safeReturnTo(url.searchParams.get("returnTo"));
const state = randomBytes(24).toString("base64url");
const nonce = randomBytes(24).toString("base64url");
const authorizeUrl = new URL(`${issuer.replace(/\/$/, "")}/protocol/openid-connect/auth`);
authorizeUrl.searchParams.set("response_type", "code");
authorizeUrl.searchParams.set("client_id", clientId);
authorizeUrl.searchParams.set("redirect_uri", this.oidcRedirectUri(request));
authorizeUrl.searchParams.set("scope", "openid profile email");
authorizeUrl.searchParams.set("state", state);
authorizeUrl.searchParams.set("nonce", nonce);
await this.store.upsertOidcState?.({
state,
nonce,
returnTo,
createdAt: this.now(),
expiresAt: new Date(Date.parse(this.now()) + 600 * 1000).toISOString()
});
sendRedirect(response, authorizeUrl.toString(), { contractVersion: "oidc-v1" });
}
async handleOidcCallback(request, response, url) {
const issuer = textOr(this.env.HWLAB_KEYCLOAK_ISSUER, "");
const clientId = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_ID, "");
const clientSecret = textOr(this.env.HWLAB_KEYCLOAK_CLIENT_SECRET, "");
if (!issuer || !clientId || !clientSecret) {
return sendJson(response, 503, errorPayload("oidc_not_configured", "OIDC issuer / client are not configured on this cloud-api", 503));
}
const code = textOr(url.searchParams.get("code"), "");
const state = textOr(url.searchParams.get("state"), "");
const error = textOr(url.searchParams.get("error"), "");
if (error) {
return sendJson(response, 400, errorPayload("oidc_error", `OIDC provider returned error: ${error}`, 400));
}
if (!code || !state) {
return sendJson(response, 400, errorPayload("oidc_missing_params", "code and state are required", 400));
}
const stored = await this.store.consumeOidcState?.(state) ?? null;
if (!stored) {
return sendJson(response, 400, errorPayload("oidc_invalid_state", "OIDC state not found or already consumed", 400));
}
if (new Date(stored.expiresAt).getTime() < Date.parse(this.now())) {
return sendJson(response, 400, errorPayload("oidc_state_expired", "OIDC state expired; retry login", 400));
}
const tokenResponse = await this.exchangeOidcCode({ issuer, clientId, clientSecret, code, request });
if (!tokenResponse.ok) {
return sendJson(response, tokenResponse.status, errorPayload(tokenResponse.code, tokenResponse.message, tokenResponse.status));
}
const idToken = await verifyOidcIdToken(tokenResponse.idToken, { issuer, clientId, nonce: stored.nonce, now: this.now(), fetchImpl: this.fetchImpl });
if (!idToken.ok) {
return sendJson(response, 400, errorPayload(idToken.code, idToken.message, 400));
}
const userinfo = await this.fetchOidcUserinfo({ issuer, accessToken: tokenResponse.accessToken });
if (!userinfo.ok) {
return sendJson(response, userinfo.status, errorPayload(userinfo.code, userinfo.message, userinfo.status));
}
if (textOr(userinfo.claims.sub, "") !== textOr(idToken.claims.sub, "")) {
return sendJson(response, 400, errorPayload("oidc_subject_mismatch", "OIDC userinfo subject does not match ID token", 400));
}
const actor = await this.upsertOidcUser({ issuer, claims: { ...idToken.claims, ...userinfo.claims, sub: idToken.claims.sub }, accessToken: tokenResponse.accessToken, refreshToken: tokenResponse.refreshToken });
if (!actor) {
return sendJson(response, 500, errorPayload("oidc_user_upsert_failed", "Failed to upsert OIDC user", 500));
}
const session = await this.issueWebSessionForActor(actor);
setSessionCookie(response, session.token, SESSION_MAX_AGE_SECONDS, request, this.env);
sendRedirect(response, stored.returnTo || "/", { contractVersion: "oidc-v1" });
}
oidcRedirectUri(request) {
const publicBase = textOr(this.env.HWLAB_PUBLIC_ENDPOINT, "");
if (publicBase) return `${publicBase.replace(/\/$/, "")}/auth/oidc/callback`;
return `${textOr(this.env.HWLAB_RUNTIME_API_URL, "http://127.0.0.1:6667")}/auth/oidc/callback`;
}
async exchangeOidcCode({ issuer, clientId, clientSecret, code, request }) {
const tokenUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/token`;
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
client_id: clientId,
client_secret: clientSecret,
redirect_uri: this.oidcRedirectUri(request)
});
try {
const response = await this.fetchImpl(tokenUrl, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/x-www-form-urlencoded" },
body: body.toString()
});
const text = await response.text();
const json = parseJson(text, {});
if (response.status >= 400 || !json.access_token || !json.id_token) {
return { ok: false, status: response.status, code: "oidc_token_exchange_failed", message: textOr(json.error_description, text) };
}
return { ok: true, accessToken: json.access_token, refreshToken: json.refresh_token ?? null, idToken: json.id_token };
} catch (error) {
return { ok: false, status: 500, code: "oidc_token_exchange_error", message: error?.message ?? String(error) };
}
}
async fetchOidcUserinfo({ issuer, accessToken }) {
try {
const userinfoUrl = `${issuer.replace(/\/$/, "")}/protocol/openid-connect/userinfo`;
const response = await this.fetchImpl(userinfoUrl, { headers: { accept: "application/json", authorization: `Bearer ${accessToken}` } });
const text = await response.text();
const claims = parseJson(text, {});
if (response.status >= 400 || !claims.sub) {
return { ok: false, status: response.status, code: "oidc_userinfo_failed", message: textOr(claims.error_description, text) };
}
return { ok: true, claims };
} catch (error) {
return { ok: false, status: 500, code: "oidc_userinfo_error", message: error?.message ?? String(error) };
}
}
async upsertOidcUser({ issuer, claims }) {
const sub = textOr(claims.sub, "");
if (!sub) return null;
const username = textOr(claims.preferred_username, claims.email ?? sub);
const email = textOr(claims.email, "");
const displayName = textOr(claims.name, username);
const existing = await this.store.findUserByKeycloakSubject?.(issuer, sub) ?? null;
if (existing) {
await this.store.updateUserOidcLogin?.({ userId: existing.id, email, lastLoginAt: this.now() });
return existing;
}
return await this.store.createUserOidc?.({
username,
displayName,
email,
keycloakIssuer: issuer,
keycloakSub: sub,
role: "user",
status: "active",
now: this.now()
});
}
async issueWebSessionForActor(actor) {
const token = randomBytes(32).toString("base64url");
const now = this.now();
const session = await this.store.createSession({
userId: actor.id,
tokenHash: sha256(token),
now,
expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString()
});
await this.store.ensureDefaultApiKeyForUser?.({ userId: actor.id, now });
return { token, session };
}
async handleLogin(request, response) {
const body = await jsonBody(request);
const user = await this.store.findUserByUsername(requiredText(body.username, "username"));
if (!user || user.status !== "active" || !verifyPassword(user.passwordHash, requiredText(body.password, "password"))) {
return sendJson(response, 401, errorPayload("invalid_credentials", "Username or password is invalid", 401));
}
const token = randomBytes(32).toString("base64url");
const now = this.now();
const session = await this.store.createSession({
userId: user.id,
tokenHash: sha256(token),
now,
expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString()
});
await this.store.ensureDefaultApiKeyForUser?.({ userId: user.id, now }) ?? null;
setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env);
return sendJson(response, 200, {
authenticated: true,
actor: publicActor(user),
session: publicSession({ ...session, user })
});
}
async handleLogout(request, response) {
const token = sessionCookieFromRequest(request);
if (token) await this.store.revokeSessionByTokenHash(sha256(token), this.now());
clearSessionCookie(response, request, this.env);
return sendJson(response, 200, { authenticated: false, loggedOut: true });
}
async codeAgentToolCapabilitiesForOwner(ownerUserId) {
await this.ensureBootstrap();
const actor = await this.store.getUserById(ownerUserId);
if (!actor || actor.status !== "active") return defaultDeniedToolCapabilities();
const entries = {};
for (const toolId of HWLAB_TOOL_IDS) {
const authorization = await this.openFga.check({ actor, relation: "can_use", object: openFgaObject("tool", toolId) });
entries[toolId] = { allowed: authorization.allowed, authorization };
}
return { contractVersion: "admin-access-v1", actor: publicActor(actor), tools: entries, valuesRedacted: true };
}
async bootstrapAdminActor() {
return await this.store.findUserByUsername(this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin")
?? await this.store.getUserById(this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin");
}
async ensureBootstrap() {
if (this.bootstrapAttempted) return;
this.bootstrapAttempted = true;
await this.store.ensureSchema?.();
const count = await this.store.countUsers();
const passwordHash = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH, "") || (this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD ? hashPassword(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD) : "");
if (count > 0) {
if (passwordHash) await this.store.syncBootstrapAdminPassword?.({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin",
passwordHash,
now: this.now()
});
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
return;
}
if (!passwordHash) return;
await this.store.createUser({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin",
displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin",
role: "admin",
status: "active",
passwordHash,
now: this.now()
});
await this.syncExistingAdminTuples();
await this.ensureBootstrapAdminApiKey();
}
async ensureBootstrapAdminApiKey() {
const secret = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_API_KEY, "");
if (!secret) return null;
if (!isApiKeySecret(secret)) throw Object.assign(new Error("HWLAB_BOOTSTRAP_ADMIN_API_KEY must start with hwl_live_"), { statusCode: 500, code: "bootstrap_admin_api_key_invalid" });
const actor = await this.bootstrapAdminActor();
if (!actor || actor.role !== "admin") return null;
const id = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_API_KEY_ID, "key_master_server_admin");
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const key = await this.store.createApiKey?.({
id,
userId: actor.id,
name: ADMIN_BOOTSTRAP_API_KEY_NAME,
keyPrefix: prefix,
keyHash: hash,
displaySecret: null,
scopes: ["admin", "system:hwlab", "tool:*"],
status: "active",
now: this.now()
}) ?? null;
await this.syncUserAdminTuple(actor, actor);
for (const toolId of HWLAB_TOOL_IDS) {
await this.grantAccessTuple({ userId: actor.id, relation: "can_use", object: openFgaObject("tool", toolId), admin: actor });
}
return key;
}
async setupRequired() {
return (await this.store.countUsers()) === 0;
}
async recordAgentSessionOwner(input = {}) {
await this.ensureBootstrap();
const ownerUserId = textOr(input.ownerUserId, "");
const sessionId = agentSessionIdForRecord(input.sessionId, input.traceId);
if (!ownerUserId || !sessionId) return null;
return this.store.recordAgentSessionOwner?.({
...input,
sessionId,
ownerUserId,
projectId: textOr(input.projectId, "prj_v02_code_agent"),
agentId: textOr(input.agentId, "hwlab-code-agent"),
status: textOr(input.status, "active"),
now: this.now()
}) ?? null;
}
async getAgentSessionByTraceId(traceId) {
await this.ensureBootstrap();
const id = textOr(traceId, "");
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(id)) return null;
return this.store.getAgentSessionByTraceId?.(id) ?? null;
}
async getAgentSession(sessionId) {
await this.ensureBootstrap();
const id = safeAgentSessionId(sessionId);
if (!id) return null;
return this.store.getAgentSession?.(id) ?? null;
}
async listAgentConversations(request, response, url) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
const projectId = textOr(url.searchParams.get("projectId"), "");
const limit = boundedListLimit(url.searchParams.get("limit"));
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
ownerScoped: true,
projectId,
limit,
includeArchived: false,
now: this.now()
}) ?? [];
const conversations = await this.repairVisibleConversationsIfNeeded(
auth.actor,
conversationsFromAgentSessions(sessions),
projectId
);
return sendJson(response, 200, {
ok: true,
status: "succeeded",
contractVersion: "agent-conversations-v1",
actor: publicActor(auth.actor),
projectId: projectId || null,
conversations,
defaultConversation: conversations[0] ?? null,
count: conversations.length
});
}
async getAgentConversation(request, response, conversationId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
const url = new URL(request.url || "/", "http://localhost");
const projectId = textOr(url.searchParams.get("projectId"), "");
if (!safeConversationIdLocal(conversationId)) {
return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
}
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
ownerScoped: true,
conversationId,
limit: 20,
includeArchived: true,
now: this.now()
}) ?? [];
let conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
conversation = await this.repairVisibleConversationIfNeeded(auth.actor, conversation, projectId) ?? conversation;
return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation });
}
async updateAgentConversation(request, response, conversationId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeConversationIdLocal(conversationId)) {
return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
}
const body = await jsonBody(request);
const sessionId = safeAgentSessionId(body.sessionId) || safeAgentSessionId(body.session?.sessionId) || `ses_${conversationId.slice(4)}`;
const session = await this.recordAgentSessionOwner({
ownerUserId: auth.actor.id,
sessionId,
projectId: textOr(body.projectId, "prj_hwpod_workbench"),
agentId: textOr(body.agentId, "hwlab-code-agent"),
status: textOr(body.status ?? body.sessionStatus, "active"),
conversationId,
threadId: textOr(body.threadId, null),
traceId: textOr(body.lastTraceId ?? body.traceId, null),
session: normalizeConversationSnapshot(body, auth.actor)
});
const conversation = conversationsFromAgentSessions(session ? [session] : []).find((item) => item.conversationId === conversationId) ?? null;
return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation });
}
async deleteAgentConversation(request, response, conversationId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeConversationIdLocal(conversationId)) {
return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
}
const body = await jsonBody(request);
const projectId = textOr(body.projectId, "prj_hwpod_workbench");
const visible = await this.visibleConversationForActor(auth.actor, conversationId, projectId, { includeArchived: true });
if (!visible) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
const archived = await this.store.archiveAgentConversation?.({
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
ownerScoped: true,
conversationId,
projectId,
now: this.now()
});
let workspace = null;
const workspaceId = textOr(body.workspaceId, "");
if (safeWorkspaceId(workspaceId)) {
const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role });
if (current?.selectedConversationId === conversationId) {
const next = await this.nextVisibleConversationForActor(auth.actor, projectId, conversationId);
workspace = await this.store.updateWorkspace?.({
workspaceId,
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
selectedConversationId: next?.conversationId ?? null,
selectedAgentSessionId: next?.sessionId ?? null,
activeTraceId: null,
patch: {
selectedConversationId: next?.conversationId ?? null,
selectedAgentSessionId: next?.sessionId ?? null,
activeTraceId: null,
messages: next?.messages ?? [],
deletedConversationId: conversationId,
source: "workbench-delete-conversation"
},
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
});
}
}
return sendJson(response, 200, {
ok: true,
status: "deleted",
contractVersion: "agent-conversations-v1",
conversationId,
archivedCount: archived?.count ?? 0,
workspace: workspace ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null
});
}
async getWorkbenchWorkspace(request, response, url) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
const projectId = textOr(url.searchParams.get("projectId"), "prj_hwpod_workbench");
let workspace = await this.store.getOrCreateDefaultWorkspace?.({
ownerUserId: auth.actor.id,
projectId,
now: this.now()
});
workspace = await this.syncTerminalWorkbenchWorkspace(workspace, auth.actor);
return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor));
}
async updateWorkbenchWorkspace(request, response, workspaceId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeWorkspaceId(workspaceId)) {
return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400));
}
const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role });
if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404));
const body = await jsonBody(request);
const expectedRevision = Number.parseInt(String(body.expectedRevision ?? body.revision ?? ""), 10);
if (Number.isInteger(expectedRevision) && expectedRevision > 0 && expectedRevision !== current.revision) {
return sendJson(response, 409, {
ok: false,
status: "revision_conflict",
error: { code: "workspace_revision_conflict", message: "Workbench workspace revision changed; reload and retry." },
expectedRevision,
workspace: await this.publicWorkbenchWorkspace(current, auth.actor)
});
}
const selectedConversationId = textOr(body.selectedConversationId ?? body.conversationId, current.selectedConversationId ?? "");
if (selectedConversationId) {
const visible = await this.visibleConversationForActor(auth.actor, selectedConversationId, body.projectId ?? current.projectId);
if (!visible) return sendJson(response, 403, errorPayload("workspace_conversation_forbidden", "Selected conversation is not visible to the current actor", 403));
}
const workspace = await this.store.updateWorkspace?.({
workspaceId,
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
projectId: textOr(body.projectId, current.projectId),
name: textOr(body.name, current.name),
status: body.status === "archived" ? "archived" : "active",
selectedConversationId,
selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId) || current.selectedAgentSessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null,
providerProfile: textOr(body.providerProfile, current.providerProfile ?? ""),
patch: normalizeWorkspacePatch(body, auth.actor),
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
});
return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor));
}
async selectWorkbenchConversation(request, response, workspaceId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400));
const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role });
if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404));
const body = await jsonBody(request);
const conversationId = textOr(body.conversationId, "") || `cnv_${randomUUID()}`;
if (!safeConversationIdLocal(conversationId)) return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400));
const existing = await this.visibleConversationForActor(auth.actor, conversationId, body.projectId ?? current.projectId);
if (!existing && body.create !== true) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404));
const sessionId = safeAgentSessionId(body.sessionId ?? existing?.sessionId) || `ses_${conversationId.slice(4)}`;
if (!existing) {
await this.recordAgentSessionOwner({
ownerUserId: auth.actor.id,
sessionId,
projectId: current.projectId,
agentId: "hwlab-code-agent",
status: "active",
conversationId,
threadId: textOr(body.threadId, null),
traceId: textOr(body.lastTraceId ?? body.traceId, null),
session: normalizeConversationSnapshot({ ...body, snapshot: { source: "workbench-select-conversation" } }, auth.actor)
});
}
const workspace = await this.store.updateWorkspace?.({
workspaceId,
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
selectedConversationId: conversationId,
selectedAgentSessionId: sessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId) || null,
patch: normalizeWorkspacePatch({ ...body, selectedConversationId: conversationId, sessionId }, auth.actor),
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
});
return sendJson(response, 200, await this.workbenchWorkspacePayload(workspace, auth.actor));
}
async resetWorkbenchWorkspace(request, response, workspaceId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
if (!safeWorkspaceId(workspaceId)) return sendJson(response, 400, errorPayload("invalid_workspace_id", "workspaceId must start with wsp_", 400));
const current = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role });
if (!current) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404));
const body = await jsonBody(request);
if (body.confirm !== true && body.confirmReset !== true) {
return sendJson(response, 400, errorPayload("workspace_reset_confirmation_required", "Reset requires confirm=true", 400));
}
const workspace = await this.store.updateWorkspace?.({
workspaceId,
ownerUserId: auth.actor.id,
actorRole: auth.actor.role,
selectedConversationId: null,
selectedAgentSessionId: null,
activeTraceId: null,
providerProfile: null,
patch: { resetAt: this.now(), resetBy: publicActor(auth.actor), messages: [] },
replaceJson: true,
updatedBySessionId: currentSessionIdFromAuth(auth),
updatedByClient: textOr(body.updatedByClient ?? body.client, "unknown"),
now: this.now()
});
return sendJson(response, 200, { ...(await this.workbenchWorkspacePayload(workspace, auth.actor)), reset: true });
}
async workbenchWorkspaceEvents(request, response, url, workspaceId) {
await this.ensureBootstrap();
const auth = await this.authenticate(request, { required: true });
if (!auth.ok) return sendJson(response, auth.status, auth);
let workspace = await this.store.getWorkspaceForUser?.({ workspaceId, ownerUserId: auth.actor.id, actorRole: auth.actor.role });
if (!workspace) return sendJson(response, 404, errorPayload("workbench_workspace_not_found", "Workbench workspace is not visible to the current actor", 404));
workspace = await this.syncTerminalWorkbenchWorkspace(workspace, auth.actor);
const afterRevision = Number.parseInt(String(url.searchParams.get("afterRevision") ?? ""), 10) || 0;
const changed = workspace.revision > afterRevision;
return sendJson(response, 200, {
ok: true,
status: changed ? "changed" : "unchanged",
contractVersion: "workbench-workspace-v1",
afterRevision,
latestRevision: workspace.revision,
events: changed ? [{ type: "workspace", revision: workspace.revision, updatedAt: workspace.updatedAt }] : [],
workspace: changed ? await this.publicWorkbenchWorkspace(workspace, auth.actor) : null
});
}
async workbenchWorkspacePayload(workspace, actor) {
return {
ok: true,
status: "succeeded",
contractVersion: "workbench-workspace-v1",
actor: publicActor(actor),
workspace: await this.publicWorkbenchWorkspace(workspace, actor)
};
}
async syncTerminalWorkbenchWorkspace(workspace, actor) {
const activeTraceId = safeTraceIdLocal(workspace?.activeTraceId);
const codeAgentEnv = this.codeAgentEnv ?? this.env;
if (!workspace || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return workspace;
const workspaceJson = normalizeObject(workspace.workspace);
const repairTraceId = activeTraceId ? "" : await this.terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson);
const traceId = activeTraceId || repairTraceId;
if (!traceId) return workspace;
try {
const result = await this.terminalCodeAgentResultForActor(traceId, actor);
if (!result) return workspace;
const now = this.now();
const staleContinuation = isThreadResumeFailedResult(result);
const resultConversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : workspace.selectedConversationId;
const resultAgentSessionId = safeAgentSessionId(result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId) || workspace.selectedAgentSessionId;
const currentSelectedConversationId = safeConversationIdLocal(workspace.selectedConversationId) ? workspace.selectedConversationId : null;
const currentSelectedAgentSessionId = safeAgentSessionId(workspace.selectedAgentSessionId) || null;
const resultMatchesCurrentSelection = Boolean(
resultConversationId &&
(!currentSelectedConversationId || currentSelectedConversationId === resultConversationId) &&
(!currentSelectedAgentSessionId || currentSelectedAgentSessionId === resultAgentSessionId)
);
const preserveCurrentSelection = Boolean(currentSelectedConversationId && !resultMatchesCurrentSelection);
const selectedConversationId = staleContinuation
? (preserveCurrentSelection ? currentSelectedConversationId : null)
: (preserveCurrentSelection ? currentSelectedConversationId : resultConversationId);
const selectedAgentSessionId = staleContinuation
? (preserveCurrentSelection ? currentSelectedAgentSessionId : null)
: (preserveCurrentSelection ? currentSelectedAgentSessionId : resultAgentSessionId);
const nextSessionStatus = preserveCurrentSelection ? workspaceJson.sessionStatus : terminalWorkbenchSessionStatus(result);
await this.syncTerminalWorkbenchConversation({
workspace,
actor,
result,
activeTraceId: traceId,
selectedConversationId: resultConversationId,
selectedAgentSessionId: resultAgentSessionId,
now
});
const staleThreadId = threadResumeFailureThreadId(result);
const updated = await this.store.updateWorkspace?.({
workspaceId: workspace.id,
ownerUserId: workspace.ownerUserId,
actorRole: actor?.role ?? "user",
selectedConversationId,
selectedAgentSessionId,
activeTraceId: null,
providerProfile: workspace.providerProfile,
patch: {
...workspaceJson,
selectedConversationId,
selectedAgentSessionId,
activeTraceId: null,
sessionStatus: nextSessionStatus,
lastTraceId: traceId,
syncedTraceStatus: result.status ?? result.agentRun?.terminalStatus ?? null,
...(staleContinuation ? {
staleContinuationCleared: true,
staleContinuationReason: "thread-resume-failed",
staleConversationId: resultConversationId,
staleAgentSessionId: resultAgentSessionId,
staleThreadId,
recoveryAction: "new-session-on-next-turn"
} : {}),
updatedAt: now,
source: "workbench-status-sync",
valuesRedacted: true,
secretMaterialStored: false
},
updatedBySessionId: workspace.updatedBySessionId,
updatedByClient: "workbench-status-sync",
now
});
return updated ?? workspace;
} catch {
return workspace;
}
}
async terminalWorkbenchRepairTraceId(workspace, actor, workspaceJson) {
try {
const traceId = safeTraceIdLocal(workspaceJson?.lastTraceId);
if (!traceId || !safeConversationIdLocal(workspace?.selectedConversationId)) return "";
const conversation = await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId, { skipTerminalRepair: true });
return conversationNeedsTerminalRepair(conversation, traceId) ? traceId : "";
} catch {
return "";
}
}
async publicWorkbenchWorkspace(workspace, actor) {
const conversation = workspace?.selectedConversationId
? await this.visibleConversationForActor(actor, workspace.selectedConversationId, workspace.projectId)
: null;
return publicWorkbenchWorkspace(workspace, { conversation });
}
async syncTerminalWorkbenchConversation({ workspace, actor, result, activeTraceId, selectedConversationId, selectedAgentSessionId, now }) {
if (!safeConversationIdLocal(selectedConversationId) || !safeAgentSessionId(selectedAgentSessionId)) return null;
const existing = await this.visibleConversationForActor(actor, selectedConversationId, workspace.projectId, { skipTerminalRepair: true });
const sessionStatus = terminalWorkbenchSessionStatus(result);
const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, existing?.threadId ?? null);
const workspaceJson = normalizeObject(workspace?.workspace);
const workspaceMessages = workspace?.selectedConversationId === selectedConversationId && workspace?.selectedAgentSessionId === selectedAgentSessionId && Array.isArray(workspaceJson.messages)
? workspaceJson.messages
: [];
const existingMessages = Array.isArray(existing?.messages) && existing.messages.length > 0 ? existing.messages : workspaceMessages;
const messages = mergeTerminalConversationMessages(existingMessages, result, {
traceId: activeTraceId,
conversationId: selectedConversationId,
sessionId: selectedAgentSessionId,
threadId,
now,
existingMessageCount: existing?.messageCount ?? existing?.snapshot?.messageCount ?? (workspaceMessages.length || null),
firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? firstUserPreviewFromMessages(workspaceMessages) ?? null
});
const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? [];
const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null;
const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null;
return await this.recordAgentSessionOwner({
ownerUserId: actor.id,
sessionId: selectedAgentSessionId,
projectId: workspace.projectId,
agentId: existing?.agentId ?? "hwlab-code-agent",
status: sessionStatus,
conversationId: selectedConversationId,
threadId,
traceId: activeTraceId,
session: normalizeConversationSnapshot({
...(existing?.snapshot ?? {}),
source: "workbench-status-sync",
sessionStatus,
updatedAt: now,
lastTraceId: activeTraceId,
messages: mergedMessages,
messageCount: mergedCount,
firstUserMessagePreview: mergedPreview
}, actor),
now
});
}
async terminalCodeAgentResultForActor(traceId, actor) {
const safeTraceId = safeTraceIdLocal(traceId);
const codeAgentEnv = this.codeAgentEnv ?? this.env;
if (!safeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return null;
const options = {
env: codeAgentEnv,
fetchImpl: this.fetchImpl,
accessController: this,
codeAgentChatResults: this.codeAgentChatResults,
traceStore: this.traceStore ?? defaultCodeAgentTraceStore
};
try {
const cached = this.codeAgentChatResults?.get?.(safeTraceId) ?? null;
const persisted = cached ? null : await loadPersistedAgentRunResult(safeTraceId, options);
const current = cached ?? persisted ?? null;
if (!current || !canActorReadWorkspaceTrace(current, actor)) return null;
const synced = current?.agentRun?.runId && current.status === "running"
? await syncAgentRunChatResult({ traceId: safeTraceId, currentResult: current, options, traceStore: options.traceStore })
: { result: current };
const result = synced.result ?? current;
return isTerminalCodeAgentResult(result) ? result : null;
} catch {
return null;
}
}
async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "") {
if (!Array.isArray(conversations) || conversations.length === 0) return [];
const repaired = [];
for (const conversation of conversations) {
repaired.push(await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation);
}
return repaired;
}
async repairVisibleConversationIfNeeded(actor, conversation, projectId = "") {
if (textOr(conversation?.status, "").toLowerCase() === "archived") return conversation;
const traceId = terminalConversationFallbackRepairTraceId(conversation);
if (!traceId) return conversation;
const result = await this.terminalCodeAgentResultForActor(traceId, actor);
if (!result) return conversation;
const sessionId = safeAgentSessionId(
result.sessionId
?? result.session?.sessionId
?? result.sessionReuse?.sessionId
?? conversation.sessionId
?? conversation.session?.sessionId
);
const conversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : conversation.conversationId;
if (!safeConversationIdLocal(conversationId) || !sessionId) return conversation;
const now = this.now();
const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, conversation.threadId ?? null);
const messages = mergeTerminalConversationMessages(conversation.messages, result, {
traceId,
conversationId,
sessionId,
threadId,
now,
existingMessageCount: conversation.messageCount ?? conversation.snapshot?.messageCount ?? null,
firstUserMessagePreview: conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview ?? null
});
const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? [];
const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null;
const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null;
const sessionStatus = terminalWorkbenchSessionStatus(result);
const repaired = await this.recordAgentSessionOwner({
ownerUserId: actor.id,
sessionId,
projectId: textOr(conversation.projectId ?? projectId, "prj_hwpod_workbench"),
agentId: conversation.agentId ?? "hwlab-code-agent",
status: sessionStatus,
conversationId,
threadId,
traceId,
session: normalizeConversationSnapshot({
...(conversation.snapshot ?? {}),
source: "conversation-terminal-repair",
sessionStatus,
updatedAt: now,
lastTraceId: traceId,
messages: mergedMessages,
messageCount: mergedCount,
firstUserMessagePreview: mergedPreview
}, actor),
now
});
return conversationsFromAgentSessions(repaired ? [repaired] : []).find((item) => item.conversationId === conversationId) ?? conversation;
}
async visibleConversationForActor(actor, conversationId, projectId = "", options = {}) {
if (!safeConversationIdLocal(conversationId)) return null;
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: actor.id,
actorRole: actor.role,
ownerScoped: true,
conversationId,
projectId: textOr(projectId, ""),
limit: 20,
includeArchived: options.includeArchived === true,
now: this.now()
}) ?? [];
const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null;
if (!conversation || options.skipTerminalRepair === true) return conversation;
return await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation;
}
async nextVisibleConversationForActor(actor, projectId = "", excludedConversationId = "") {
const sessions = await this.store.listAgentSessionsForUser?.({
ownerUserId: actor.id,
actorRole: actor.role,
ownerScoped: true,
projectId: textOr(projectId, ""),
limit: 20,
includeArchived: false,
now: this.now()
}) ?? [];
return conversationsFromAgentSessions(sessions).find((item) => item.conversationId !== excludedConversationId) ?? null;
}
}
class MemoryAccessStore {
constructor({ now = () => new Date().toISOString() } = {}) {
this.now = now;
this.users = new Map();
this.sessions = new Map();
this.apiKeys = new Map();
this.oidcStates = new Map();
this.agentSessions = new Map();
this.workspaces = new Map();
this.accessConfig = new Map();
this.accessTuples = new Map();
}
async countUsers() { return this.users.size; }
async getUserById(id) { return this.users.get(id) ?? null; }
async listUsers() { return [...this.users.values()].sort((a, b) => String(a.username).localeCompare(String(b.username))); }
async findUserByUsername(username) { return [...this.users.values()].find((user) => user.username === username) ?? null; }
async updateUserAccess(input = {}) {
const user = this.users.get(textOr(input.userId, ""));
if (!user) return null;
const next = { ...user, role: input.role ?? user.role, status: input.status ?? user.status, updatedAt: input.now ?? this.now() };
this.users.set(next.id, next);
return next;
}
async syncBootstrapAdminPassword(input) {
const existing = await this.findUserByUsername(input.username) ?? await this.getUserById(input.id);
if (!existing) return null;
const user = { ...existing, role: "admin", status: "active", passwordHash: input.passwordHash, updatedAt: input.now ?? this.now() };
this.users.set(user.id, user);
return user;
}
async createUser(input) {
const now = input.now ?? this.now();
const existing = await this.findUserByUsername(input.username);
const user = { id: input.id ?? existing?.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? existing?.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? existing?.passwordHash ?? null, authProvider: textOr(input.authProvider, existing?.authProvider ?? "local"), keycloakIssuer: textOr(input.keycloakIssuer, existing?.keycloakIssuer ?? "") || null, keycloakSub: textOr(input.keycloakSub, existing?.keycloakSub ?? "") || null, email: textOr(input.email, existing?.email ?? "") || null, lastLoginAt: textOr(input.lastLoginAt, existing?.lastLoginAt ?? "") || null, createdAt: existing?.createdAt ?? now, updatedAt: now };
this.users.set(user.id, user);
return user;
}
async createFirstAdmin(input) {
if (this.users.size > 0) return null;
return this.createUser({ ...input, role: "admin", status: "active" });
}
async createSession(input) {
const session = { id: `uss_${randomUUID()}`, userId: input.userId, tokenHash: input.tokenHash, createdAt: input.now, lastSeenAt: input.now, expiresAt: input.expiresAt, revokedAt: null };
this.sessions.set(session.id, session);
return session;
}
async findSessionByTokenHash(tokenHash, now) {
const session = [...this.sessions.values()].find((item) => item.tokenHash === tokenHash && !item.revokedAt && item.expiresAt > now) ?? null;
return session ? { ...session, user: this.users.get(session.userId) ?? null } : null;
}
async touchSession(id, now) { const session = this.sessions.get(id); if (session) session.lastSeenAt = now; }
async revokeSessionByTokenHash(tokenHash, now) { for (const session of this.sessions.values()) if (session.tokenHash === tokenHash) session.revokedAt = now; }
async createApiKey(input) {
const now = input.now ?? this.now();
const key = { id: input.id ?? `key_${randomUUID()}`, userId: input.userId, name: input.name ?? API_KEY_DEFAULT_NAME, keyPrefix: input.keyPrefix, keyHash: input.keyHash ?? null, displaySecret: input.displaySecret ?? null, scopes: Array.isArray(input.scopes) ? input.scopes : [], status: input.status ?? "active", createdAt: now, lastUsedAt: null, revokedAt: null };
this.apiKeys.set(key.id, key);
return key;
}
async findApiKeyById(id) { const key = this.apiKeys.get(textOr(id, "")); if (!key || key.status !== "active") return null; return key; }
async findApiKeyByPrefix(prefix) { if (!prefix) return null; for (const key of this.apiKeys.values()) { if (key.status === "active" && key.keyPrefix === prefix) return key; } return null; }
async findApiKeyByHash(hash) { if (!hash) return null; for (const key of this.apiKeys.values()) { if (key.status === "active" && key.keyHash === hash) return key; } return null; }
async findApiKeyWithHashByPrefix(prefix) { if (!prefix) return null; for (const key of this.apiKeys.values()) { if (key.keyPrefix === prefix) return key; } return null; }
async listApiKeysForUser(userId) { return [...this.apiKeys.values()].filter((key) => key.userId === userId).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""))); }
async findActiveDefaultApiKeyForUser(userId) { for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active" && key.name === API_KEY_DEFAULT_NAME) return key; } return null; }
async touchApiKey(id, now) { const key = this.apiKeys.get(id); if (key) key.lastUsedAt = now; }
async revokeApiKey(id, now) { const key = this.apiKeys.get(id); if (key) { key.status = "revoked"; key.revokedAt = now; } return key ?? null; }
async revokeApiKeysForUser(userId, now) { let count = 0; for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active") { key.status = "revoked"; key.revokedAt = now; count += 1; } } return count; }
async upsertOidcState(input) { this.oidcStates.set(input.state, { state: input.state, nonce: input.nonce, returnTo: input.returnTo, createdAt: input.createdAt, expiresAt: input.expiresAt }); return { ok: true }; }
async consumeOidcState(state) { const entry = this.oidcStates.get(state) ?? null; if (entry) this.oidcStates.delete(state); return entry; }
async findUserByKeycloakSubject(issuer, sub) { for (const user of this.users.values()) { if (user.authProvider === "keycloak" && user.keycloakIssuer === issuer && user.keycloakSub === sub) return user; } return null; }
async createUserOidc(input) { const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const user = { id, username: input.username, displayName: input.displayName ?? input.username, role: input.role ?? "user", status: input.status ?? "active", passwordHash: null, authProvider: "keycloak", keycloakIssuer: input.keycloakIssuer, keycloakSub: input.keycloakSub, email: input.email, lastLoginAt: now, createdAt: now, updatedAt: now }; this.users.set(id, user); return user; }
async updateUserOidcLogin(input) { const user = this.users.get(input.userId); if (!user) return null; this.users.set(input.userId, { ...user, email: input.email, lastLoginAt: input.lastLoginAt, updatedAt: this.now() }); return this.users.get(input.userId); }
async ensureDefaultApiKeyForUser(input = {}) {
const existing = await this.findActiveDefaultApiKeyForUser(input.userId);
if (existing) return { key: existing, created: false };
const secret = input.secret ?? generateApiKey();
const prefix = apiKeyPrefixOf(secret);
const hash = hashApiKey(secret);
const key = await this.createApiKey({ userId: input.userId, name: API_KEY_DEFAULT_NAME, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: input.now });
return { key, secret, created: true };
}
async getAccessConfig(key) { return this.accessConfig.get(textOr(key, "")) ?? ""; }
async setAccessConfig(input = {}) { this.accessConfig.set(textOr(input.key, ""), textOr(input.value, "")); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; }
async upsertAccessTuple(input = {}) {
const tuple = { userId: input.userId, relation: input.relation, object: input.object, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() };
this.accessTuples.set(accessTupleKey(tuple), tuple);
return tuple;
}
async deleteAccessTuple(input = {}) { this.accessTuples.delete(accessTupleKey(input)); }
async listAccessTuples(input = {}) {
return [...this.accessTuples.values()]
.filter((tuple) => !input.userId || tuple.userId === input.userId)
.filter((tuple) => !input.object || tuple.object === input.object)
.sort((a, b) => `${a.object}:${a.relation}`.localeCompare(`${b.object}:${b.relation}`));
}
async recordAgentSessionOwner(input) {
const now = input.now ?? this.now();
const existing = this.agentSessions.get(input.sessionId) ?? null;
const session = normalizeAgentSessionOwnerRecord(input, existing, now);
this.agentSessions.set(session.id, session);
return session;
}
async getAgentSession(id) { return this.agentSessions.get(id) ?? null; }
async getAgentSessionByTraceId(traceId) {
return [...this.agentSessions.values()]
.filter((session) => session.lastTraceId === traceId || Boolean(agentSessionTraceEvidence(session, traceId)) || agentSessionContainsTraceId(session, traceId))
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))[0] ?? null;
}
async listAgentSessionsForUser(input = {}) {
const ownerUserId = textOr(input.ownerUserId, "");
const role = textOr(input.actorRole, "user");
const projectId = textOr(input.projectId, "");
const conversationId = textOr(input.conversationId, "");
const limit = boundedListLimit(input.limit);
return [...this.agentSessions.values()]
.filter((session) => input.ownerScoped === true || role !== "admin" ? session.ownerUserId === ownerUserId : true)
.filter((session) => !projectId || session.projectId === projectId)
.filter((session) => !conversationId || session.conversationId === conversationId)
.filter((session) => input.includeArchived === true || session.status !== "archived")
.sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")))
.slice(0, limit);
}
async archiveAgentConversation(input = {}) {
const ownerUserId = textOr(input.ownerUserId, "");
const role = textOr(input.actorRole, "user");
const conversationId = textOr(input.conversationId, "");
const projectId = textOr(input.projectId, "");
const now = input.now ?? this.now();
let count = 0;
for (const [id, session] of this.agentSessions.entries()) {
if ((input.ownerScoped === true || role !== "admin") && session.ownerUserId !== ownerUserId) continue;
if (conversationId && session.conversationId !== conversationId) continue;
if (projectId && session.projectId !== projectId) continue;
this.agentSessions.set(id, { ...session, status: "archived", endedAt: session.endedAt ?? now, updatedAt: now });
count += 1;
}
return { count };
}
async getOrCreateDefaultWorkspace(input = {}) {
const ownerUserId = textOr(input.ownerUserId, "");
const projectId = textOr(input.projectId, "prj_hwpod_workbench");
const existing = [...this.workspaces.values()].find((workspace) => workspace.ownerUserId === ownerUserId && workspace.projectId === projectId && workspace.isDefault === true && workspace.status === "active") ?? null;
if (existing) return existing;
const now = input.now ?? this.now();
const workspace = normalizeWorkspaceRecord({
id: defaultWorkspaceId(ownerUserId, projectId),
ownerUserId,
projectId,
now
}, null, now, { create: true });
this.workspaces.set(workspace.id, workspace);
return workspace;
}
async getWorkspaceForUser(input = {}) {
const workspace = this.workspaces.get(textOr(input.workspaceId, "")) ?? null;
if (!workspace) return null;
if (input.actorRole === "admin" || workspace.ownerUserId === input.ownerUserId) return workspace;
return null;
}
async updateWorkspace(input = {}) {
const current = await this.getWorkspaceForUser(input);
if (!current) return null;
const now = input.now ?? this.now();
const workspace = normalizeWorkspaceRecord(input, current, now, { replaceJson: input.replaceJson === true });
this.workspaces.set(workspace.id, workspace);
return workspace;
}
}
class PostgresAccessStore extends MemoryAccessStore {
constructor({ query, now } = {}) {
super({ now });
this.query = query;
this.schemaReady = false;
}
async ensureSchema() {
if (this.schemaReady) return;
for (const sql of ACCESS_SCHEMA_STATEMENTS) await this.query(sql, []);
this.schemaReady = true;
}
async countUsers() { await this.ensureSchema(); const result = await this.query("SELECT COUNT(*)::int AS count FROM users", []); return Number(result.rows?.[0]?.count ?? 0); }
async getUserById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE id = $1 LIMIT 1", [id]); return pgUser(result.rows?.[0]); }
async listUsers() { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users ORDER BY username", []); return result.rows.map(pgUser); }
async findUserByUsername(username) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE username = $1 LIMIT 1", [username]); return pgUser(result.rows?.[0]); }
async updateUserAccess(input = {}) { await this.ensureSchema(); const result = await this.query("UPDATE users SET role = $2, status = $3, updated_at = $4 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.userId, input.role, input.status, input.now ?? this.now()]); return pgUser(result.rows?.[0]); }
async syncBootstrapAdminPassword(input) {
await this.ensureSchema();
const result = await this.query("UPDATE users SET role = 'admin', status = 'active', password_hash = $3, updated_at = $4 WHERE username = $1 OR id = $2 RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.username, input.id, input.passwordHash, input.now ?? this.now()]);
return pgUser(result.rows?.[0]);
}
async createUser(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
const user = { id: input.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now };
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]);
return pgUser(result.rows?.[0]);
}
async createFirstAdmin(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
const user = { id: input.id, username: input.username, displayName: input.displayName ?? "", role: "admin", status: "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now };
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE NOT EXISTS (SELECT 1 FROM users) ON CONFLICT DO NOTHING RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]);
return pgUser(result.rows?.[0]);
}
async createSession(input) { await this.ensureSchema(); const session = await super.createSession(input); await this.query("INSERT INTO user_sessions (id, user_id, session_token_hash, created_at, last_seen_at, expires_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7)", [session.id, session.userId, session.tokenHash, session.createdAt, session.lastSeenAt, session.expiresAt, session.revokedAt]); return session; }
async findSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); const result = await this.query("SELECT s.id, s.user_id, s.session_token_hash, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.username, u.display_name, u.role, u.status, u.password_hash, u.created_at AS user_created_at, u.updated_at AS user_updated_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 LIMIT 1", [tokenHash, now]); const row = result.rows?.[0]; return row ? pgSession(row) : null; }
async touchSession(id, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET last_seen_at = $2 WHERE id = $1", [id, now]); }
async revokeSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET revoked_at = $2 WHERE session_token_hash = $1", [tokenHash, now]); }
async createApiKey(input) {
await this.ensureSchema();
const key = await super.createApiKey(input);
await this.query("INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, key_prefix = EXCLUDED.key_prefix, key_hash = EXCLUDED.key_hash, display_secret = EXCLUDED.display_secret, scopes_json = EXCLUDED.scopes_json, status = EXCLUDED.status, last_used_at = EXCLUDED.last_used_at, revoked_at = EXCLUDED.revoked_at", [key.id, key.userId, key.name, key.keyPrefix, key.keyHash, key.displaySecret, stableJson(key.scopes ?? []), key.status, key.createdAt, key.lastUsedAt, key.revokedAt]);
return key;
}
async findApiKeyById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE id = $1 AND status = 'active' LIMIT 1", [id]); return pgApiKey(result.rows?.[0]); }
async findApiKeyByPrefix(prefix) { await this.ensureSchema(); if (!prefix) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_prefix = $1 AND status = 'active' LIMIT 1", [prefix]); return pgApiKey(result.rows?.[0]); }
async findApiKeyByHash(hash) { await this.ensureSchema(); if (!hash) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_hash = $1 AND status = 'active' LIMIT 1", [hash]); return pgApiKey(result.rows?.[0]); }
async findApiKeyWithHashByPrefix(prefix) { await this.ensureSchema(); if (!prefix) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_prefix = $1 LIMIT 1", [prefix]); return pgApiKey(result.rows?.[0]); }
async listApiKeysForUser(userId) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC", [userId]); return result.rows.map(pgApiKey); }
async findActiveDefaultApiKeyForUser(userId) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = $1 AND name = $2 AND status = 'active' LIMIT 1", [userId, API_KEY_DEFAULT_NAME]); return pgApiKey(result.rows?.[0]); }
async touchApiKey(id, now) { await this.ensureSchema(); await this.query("UPDATE api_keys SET last_used_at = $2 WHERE id = $1", [id, now]); }
async revokeApiKey(id, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE id = $1 RETURNING id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at", [id, now]); return pgApiKey(result.rows?.[0]); }
async revokeApiKeysForUser(userId, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE user_id = $1 AND status = 'active' RETURNING id", [userId, now]); return result.rows?.length ?? 0; }
async upsertOidcState(input) { await this.ensureSchema(); await this.query("INSERT INTO oidc_states (state, nonce, return_to, created_at, expires_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (state) DO UPDATE SET nonce = EXCLUDED.nonce, return_to = EXCLUDED.return_to, created_at = EXCLUDED.created_at, expires_at = EXCLUDED.expires_at", [input.state, input.nonce, input.returnTo ?? "", input.createdAt, input.expiresAt]); return { ok: true }; }
async consumeOidcState(state) { await this.ensureSchema(); const result = await this.query("DELETE FROM oidc_states WHERE state = $1 RETURNING state, nonce, return_to, created_at, expires_at", [state]); const row = result.rows?.[0]; if (!row) return null; return { state: row.state, nonce: row.nonce, returnTo: row.return_to, createdAt: row.created_at, expiresAt: row.expires_at }; }
async findUserByKeycloakSubject(issuer, sub) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at FROM users WHERE keycloak_issuer = $1 AND keycloak_sub = $2 LIMIT 1", [issuer, sub]); return pgUserWithOIDC(result.rows?.[0]); }
async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); }
async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); }
async getAccessConfig(key) { await this.ensureSchema(); const result = await this.query("SELECT value FROM access_config WHERE key = $1 LIMIT 1", [key]); return textOr(result.rows?.[0]?.value, ""); }
async setAccessConfig(input = {}) { await this.ensureSchema(); await this.query("INSERT INTO access_config (key, value, updated_at) VALUES ($1,$2,$3) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = EXCLUDED.updated_at", [input.key, input.value, input.updatedAt ?? this.now()]); return { key: input.key, value: input.value, updatedAt: input.updatedAt ?? this.now() }; }
async upsertAccessTuple(input = {}) { await this.ensureSchema(); const tuple = { userId: input.userId, relation: input.relation, object: input.object, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; await this.query("INSERT INTO access_tuples (user_id, relation, object, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (user_id, relation, object) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [tuple.userId, tuple.relation, tuple.object, tuple.createdByAdminId, tuple.createdAt]); return tuple; }
async deleteAccessTuple(input = {}) { await this.ensureSchema(); await this.query("DELETE FROM access_tuples WHERE user_id = $1 AND relation = $2 AND object = $3", [input.userId, input.relation, input.object]); }
async listAccessTuples(input = {}) { await this.ensureSchema(); const params = []; const clauses = []; if (input.userId) { params.push(input.userId); clauses.push(`user_id = $${params.length}`); } if (input.object) { params.push(input.object); clauses.push(`object = $${params.length}`); } const sql = `SELECT user_id, relation, object, created_by_admin_id, created_at FROM access_tuples ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY object, relation, user_id`; const result = await this.query(sql, params); return result.rows.map(pgAccessTuple); }
async recordAgentSessionOwner(input) {
await this.ensureSchema();
const now = input.now ?? this.now();
const existing = input.sessionId ? await this.getAgentSession(input.sessionId) : null;
const session = normalizeAgentSessionOwnerRecord(input, existing, now);
const result = await this.query("INSERT INTO agent_sessions (id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (id) DO UPDATE SET project_id = COALESCE(EXCLUDED.project_id, agent_sessions.project_id), agent_id = EXCLUDED.agent_id, status = EXCLUDED.status, ended_at = EXCLUDED.ended_at, owner_user_id = EXCLUDED.owner_user_id, conversation_id = COALESCE(EXCLUDED.conversation_id, agent_sessions.conversation_id), thread_id = COALESCE(EXCLUDED.thread_id, agent_sessions.thread_id), last_trace_id = COALESCE(EXCLUDED.last_trace_id, agent_sessions.last_trace_id), session_json = EXCLUDED.session_json, updated_at = EXCLUDED.updated_at RETURNING id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at", [session.id, session.projectId, session.agentId, session.status, session.startedAt, session.endedAt, session.ownerUserId, session.conversationId, session.threadId, session.lastTraceId, stableJson(session.session ?? {}), session.updatedAt]);
return pgAgentSession(result.rows?.[0]);
}
async getAgentSession(id) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE id = $1 LIMIT 1", [id]); return pgAgentSession(result.rows?.[0]); }
async getAgentSessionByTraceId(traceId) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE last_trace_id = $1 OR session_json LIKE $2 ORDER BY updated_at DESC NULLS LAST LIMIT 1", [traceId, `%${traceId}%`]); return pgAgentSession(result.rows?.[0]); }
async listAgentSessionsForUser(input = {}) {
await this.ensureSchema();
const role = textOr(input.actorRole, "user");
const ownerUserId = textOr(input.ownerUserId, "");
const projectId = textOr(input.projectId, "");
const conversationId = textOr(input.conversationId, "");
const limit = boundedListLimit(input.limit);
const clauses = [];
const params = [];
if (input.ownerScoped === true || role !== "admin") {
params.push(ownerUserId);
clauses.push(`owner_user_id = $${params.length}`);
}
if (projectId) {
params.push(projectId);
clauses.push(`project_id = $${params.length}`);
}
if (conversationId) {
params.push(conversationId);
clauses.push(`conversation_id = $${params.length}`);
}
if (input.includeArchived !== true) clauses.push("status <> 'archived'");
params.push(limit);
const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST LIMIT $${params.length}`;
const result = await this.query(sql, params);
return result.rows.map(pgAgentSession);
}
async archiveAgentConversation(input = {}) {
await this.ensureSchema();
const role = textOr(input.actorRole, "user");
const ownerUserId = textOr(input.ownerUserId, "");
const conversationId = textOr(input.conversationId, "");
const projectId = textOr(input.projectId, "");
const now = input.now ?? this.now();
const clauses = ["conversation_id = $1"];
const params = [conversationId];
if (input.ownerScoped === true || role !== "admin") {
params.push(ownerUserId);
clauses.push(`owner_user_id = $${params.length}`);
}
if (projectId) {
params.push(projectId);
clauses.push(`project_id = $${params.length}`);
}
params.push(now);
const sql = `UPDATE agent_sessions SET status = 'archived', ended_at = COALESCE(ended_at, $${params.length}), updated_at = $${params.length} WHERE ${clauses.join(" AND ")} RETURNING id`;
const result = await this.query(sql, params);
return { count: result.rows?.length ?? 0 };
}
async getOrCreateDefaultWorkspace(input = {}) {
await this.ensureSchema();
const ownerUserId = textOr(input.ownerUserId, "");
const projectId = textOr(input.projectId, "prj_hwpod_workbench");
const found = await this.query("SELECT * FROM account_workspaces WHERE owner_user_id = $1 AND project_id = $2 AND is_default = TRUE AND status = 'active' ORDER BY updated_at DESC LIMIT 1", [ownerUserId, projectId]);
const existing = pgWorkspace(found.rows?.[0]);
if (existing) return existing;
const now = input.now ?? this.now();
const workspace = normalizeWorkspaceRecord({ id: defaultWorkspaceId(ownerUserId, projectId), ownerUserId, projectId, now }, null, now, { create: true });
const result = await this.query("INSERT INTO account_workspaces (id, owner_user_id, project_id, name, status, is_default, selected_conversation_id, selected_agent_session_id, active_trace_id, provider_profile, workspace_json, revision, updated_by_session_id, updated_by_client, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT (id) DO UPDATE SET updated_at = account_workspaces.updated_at RETURNING *", workspaceParams(workspace));
return pgWorkspace(result.rows?.[0]) ?? workspace;
}
async getWorkspaceForUser(input = {}) {
await this.ensureSchema();
const workspaceId = textOr(input.workspaceId, "");
const params = [workspaceId];
let sql = "SELECT * FROM account_workspaces WHERE id = $1";
if (input.actorRole !== "admin") {
params.push(textOr(input.ownerUserId, ""));
sql += " AND owner_user_id = $2";
}
sql += " LIMIT 1";
const result = await this.query(sql, params);
return pgWorkspace(result.rows?.[0]);
}
async updateWorkspace(input = {}) {
await this.ensureSchema();
const current = await this.getWorkspaceForUser(input);
if (!current) return null;
const now = input.now ?? this.now();
const workspace = normalizeWorkspaceRecord(input, current, now, { replaceJson: input.replaceJson === true });
const result = await this.query("UPDATE account_workspaces SET project_id=$3, name=$4, status=$5, is_default=$6, selected_conversation_id=$7, selected_agent_session_id=$8, active_trace_id=$9, provider_profile=$10, workspace_json=$11, revision=$12, updated_by_session_id=$13, updated_by_client=$14, created_at=$15, updated_at=$16 WHERE id=$1 AND ($17::text = 'admin' OR owner_user_id=$2) RETURNING *", [...workspaceParams(workspace), textOr(input.actorRole, "user")]);
return pgWorkspace(result.rows?.[0]);
}
}
function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; }
function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; }
function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; }
async function jsonBody(request) {
const text = await readBody(request);
try { return text ? JSON.parse(text) : {}; } catch (error) { throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400, code: "parse_error", reason: error.message }); }
}
function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; }
function safeTraceIdLocal(value) { const text = textOr(value, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; }
function safeWorkspaceId(value) { return /^wsp_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); }
function apiKeyFromRequest(request) {
const auth = getHeader(request, "authorization");
if (!/^Bearer\s+/iu.test(String(auth ?? ""))) return "";
const token = String(auth).replace(/^Bearer\s+/iu, "").trim();
return isApiKeySecret(token) ? token : "";
}
function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; }
function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); }
function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); }
function generateApiKey() {
const secret = randomBytes(API_KEY_RANDOM_BYTES).toString("base64url");
return `${API_KEY_PREFIX}${secret}`;
}
function apiKeyPrefixOf(secret) {
const text = textOr(secret, "");
if (!text.startsWith(API_KEY_PREFIX)) return "";
const remainder = text.slice(API_KEY_PREFIX.length);
const dot = remainder.indexOf(".");
const segment = dot === -1 ? remainder : remainder.slice(0, dot);
return `${API_KEY_PREFIX}${segment}`.slice(0, 24);
}
function hashApiKey(secret) {
const prefix = apiKeyPrefixOf(secret);
if (!prefix) return null;
return `sha256:${sha256(`${prefix}:${secret}`)}`;
}
function isApiKeySecret(value) { return textOr(value, "").startsWith(API_KEY_PREFIX); }
function stableJson(value) { return JSON.stringify(sortJson(value)); }
function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; }
function accessTupleKey(tuple = {}) { return `${tuple.userId}\u0000${tuple.relation}\u0000${tuple.object}`; }
function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin" }; }
function normalizeToolId(value) { return textOr(value, "").replace(/-/gu, "_"); }
function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; }
function publicAuthMethod(method) {
return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
}
function publicApiKey(key, { includeSecret = false } = {}) {
if (!key) return null;
return {
id: key.id,
userId: key.userId,
name: key.name,
keyPrefix: key.keyPrefix,
scopes: Array.isArray(key.scopes) ? key.scopes : [],
status: key.status,
createdAt: key.createdAt,
lastUsedAt: key.lastUsedAt ?? null,
revokedAt: key.revokedAt ?? null,
...(includeSecret && key.displaySecret ? { displaySecret: key.displaySecret } : {})
};
}
function redactedUser(user) { return publicActor(user); }
function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; }
function publicAccessTuple(tuple) { return tuple ? { userId: tuple.userId, relation: tuple.relation, object: tuple.object, createdByAdminId: tuple.createdByAdminId, createdAt: tuple.createdAt } : null; }
function toolsFromTuples(tuples = []) { return Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); }
function defaultDeniedToolCapabilities() { return { contractVersion: "admin-access-v1", actor: null, tools: Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, { allowed: false, authorization: { contractVersion: "openfga-authorization-v1", mode: "enforce", allowed: false, decisionSource: "missing-owner", degraded: false } }])), valuesRedacted: true }; }
function normalizeAgentSessionOwnerRecord(input, existing, now) {
const nextTraceId = safeTraceIdLocal(input.traceId ?? input.lastTraceId);
const existingLastTraceId = safeTraceIdLocal(existing?.lastTraceId);
const historicalTraceUpdate = Boolean(nextTraceId && existingLastTraceId && nextTraceId !== existingLastTraceId && isHistoricalTraceBackfill(input.session, nextTraceId));
return {
id: input.sessionId,
projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent",
agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent",
status: input.status ?? existing?.status ?? "active",
startedAt: existing?.startedAt ?? input.startedAt ?? now,
endedAt: input.endedAt ?? existing?.endedAt ?? null,
ownerUserId: input.ownerUserId,
conversationId: input.conversationId ?? existing?.conversationId ?? null,
threadId: input.threadId ?? existing?.threadId ?? null,
lastTraceId: historicalTraceUpdate ? existingLastTraceId : nextTraceId || existingLastTraceId || null,
session: mergeAgentSessionOwnerEvidence(input.session, existing?.session, { historicalTraceUpdate }),
updatedAt: now
};
}
function isHistoricalTraceBackfill(sessionValue, traceId) {
const snapshot = normalizeObject(sessionValue);
if (snapshot.historicalTraceBackfill === true || snapshot.source === "agent-session-trace-repair") return true;
if (Array.isArray(snapshot.messages) && snapshot.messages.length > 0) return false;
if (agentSessionTraceEvidence({ session: snapshot }, traceId)) return true;
return Boolean(snapshot.finalResponse || snapshot.traceSummary || snapshot.agentRun);
}
function mergeAgentSessionOwnerEvidence(nextValue, existingValue, options = {}) {
const existing = normalizeObject(existingValue);
const next = normalizeObject(nextValue);
const merged = { ...existing, ...next };
merged.traceResults = mergeAgentSessionTraceResults(mergeAgentSessionTraceResults(existing.traceResults, existing), next.traceResults ?? next.traceResult ?? next);
if (options.historicalTraceUpdate === true) {
for (const field of ["agentRun", "finalResponse", "traceSummary", "traceId", "sessionStatus", "sessionLifecycleStatus"]) {
if (existing[field] !== undefined) merged[field] = existing[field];
else delete merged[field];
}
}
const existingMessages = Array.isArray(existing.messages) ? existing.messages : null;
const nextMessages = Array.isArray(next.messages) ? next.messages : null;
if (nextMessages?.length) {
merged.messages = mergeAgentSessionOwnerMessages(nextMessages, existingMessages, {
traceId: next.lastTraceId ?? next.traceId ?? existing.lastTraceId ?? existing.traceId ?? firstMessageTraceId(nextMessages) ?? firstMessageTraceId(existingMessages),
conversationId: next.conversationId ?? existing.conversationId ?? firstMessageField(nextMessages, "conversationId") ?? firstMessageField(existingMessages, "conversationId"),
sessionId: next.sessionId ?? existing.sessionId ?? firstMessageField(nextMessages, "sessionId") ?? firstMessageField(existingMessages, "sessionId"),
threadId: next.threadId ?? existing.threadId ?? firstMessageField(nextMessages, "threadId") ?? firstMessageField(existingMessages, "threadId"),
now: next.updatedAt ?? existing.updatedAt ?? firstMessageField(nextMessages, "updatedAt") ?? firstMessageField(nextMessages, "createdAt") ?? firstMessageField(existingMessages, "updatedAt") ?? firstMessageField(existingMessages, "createdAt"),
firstUserPreview: firstUserPreviewFromMessages(nextMessages) ?? next.firstUserMessagePreview ?? firstUserPreviewFromMessages(existingMessages) ?? existing.firstUserMessagePreview
});
} else if (existingMessages?.length) {
merged.messages = existingMessages;
}
merged.messages = normalizeFoldedProgressConversationMessages(normalizeFinalResponseConversationMessages(merged.messages, merged.finalResponse));
const existingChatMessages = Array.isArray(existing.chatMessages) ? existing.chatMessages : null;
const nextChatMessages = Array.isArray(next.chatMessages) ? next.chatMessages : null;
if (existingChatMessages?.length && (!nextChatMessages || nextChatMessages.length === 0)) merged.chatMessages = existingChatMessages;
if (!next.agentRun && existing.agentRun) merged.agentRun = existing.agentRun;
const activeTraceEvidence = latestAgentSessionTraceEvidence(merged);
if (activeTraceEvidence) {
if (!merged.finalResponse && activeTraceEvidence.finalResponse) merged.finalResponse = activeTraceEvidence.finalResponse;
if (!merged.traceSummary && activeTraceEvidence.traceSummary) merged.traceSummary = activeTraceEvidence.traceSummary;
}
const nextCount = numberOrNull(next.messageCount);
const existingCount = numberOrNull(existing.messageCount);
if (Array.isArray(merged.messages)) {
merged.messageCount = merged.messages.length;
} else if (nextCount !== null || existingCount !== null) {
merged.messageCount = Math.max(nextCount ?? 0, existingCount ?? 0);
}
if (!next.firstUserMessagePreview && existing.firstUserMessagePreview) {
merged.firstUserMessagePreview = existing.firstUserMessagePreview;
} else if (next.firstUserMessagePreview && !existing.firstUserMessagePreview) {
merged.firstUserMessagePreview = next.firstUserMessagePreview;
}
return merged;
}
function mergeAgentSessionTraceResults(existingValue, nextValue) {
const records = new Map();
for (const source of [existingValue, nextValue]) {
for (const entry of traceResultEntries(source)) {
const normalized = normalizeAgentSessionTraceResult(entry);
if (!normalized) continue;
records.set(normalized.traceId, { ...(records.get(normalized.traceId) ?? {}), ...normalized });
}
}
const items = [...records.values()]
.sort((a, b) => String(b.updatedAt ?? b.createdAt ?? "").localeCompare(String(a.updatedAt ?? a.createdAt ?? "")))
.slice(0, 50);
return Object.fromEntries(items.reverse().map((item) => [item.traceId, item]));
}
function traceResultEntries(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
if (typeof value === "object") {
if (safeTraceIdLocal(value.traceId)) return [value];
return Object.entries(value)
.filter(([key]) => safeTraceIdLocal(key))
.map(([traceId, entry]) => ({ ...normalizeObject(entry), traceId }));
}
return [];
}
function normalizeAgentSessionTraceResult(value) {
const record = normalizeObject(value);
const traceId = safeTraceIdLocal(record.traceId);
if (!traceId) return null;
const agentRun = normalizeObject(record.agentRun);
const result = pruneEmpty({
traceId,
status: textOr(record.status, ""),
conversationId: textOr(record.conversationId, ""),
sessionId: textOr(record.sessionId, ""),
threadId: textOr(record.threadId, ""),
messageId: textOr(record.messageId, ""),
createdAt: textOr(record.createdAt, ""),
updatedAt: textOr(record.updatedAt, ""),
finalResponse: redactTraceFinalResponse(record.finalResponse),
traceSummary: redactTraceSummary(record.traceSummary),
agentRun: redactAgentRunSummary(agentRun),
valuesRedacted: true,
secretMaterialStored: false
});
return result.agentRun?.runId || result.finalResponse || result.traceSummary ? result : null;
}
function agentSessionTraceEvidence(session, traceId) {
const id = safeTraceIdLocal(traceId);
if (!id) return null;
const snapshot = normalizeObject(session?.session);
return normalizeAgentSessionTraceResult(snapshot.traceResults?.[id] ?? snapshot.traceResult?.[id] ?? null);
}
function latestAgentSessionTraceEvidence(snapshot) {
const id = safeTraceIdLocal(snapshot?.lastTraceId ?? snapshot?.traceId);
if (!id) return null;
return normalizeAgentSessionTraceResult(snapshot.traceResults?.[id] ?? null);
}
function agentSessionContainsTraceId(session, traceId) {
const id = safeTraceIdLocal(traceId);
if (!id) return false;
const snapshot = normalizeObject(session?.session);
if (safeTraceIdLocal(snapshot.lastTraceId ?? snapshot.traceId) === id) return true;
return [snapshot.messages, snapshot.chatMessages].some((messages) => Array.isArray(messages) && messages.some((message) => safeTraceIdLocal(message?.traceId) === id));
}
function normalizeFinalResponseConversationMessages(messages, finalResponse) {
const finalText = textOr(finalResponse?.text, "");
const traceId = safeTraceIdLocal(finalResponse?.traceId);
if (!Array.isArray(messages) || !finalText) return messages;
return messages.map((message) => {
if (message?.role !== "agent") return message;
const sameTrace = traceId && safeTraceIdLocal(message.traceId) === traceId;
if (!sameTrace && !isFoldedProgressBeforeFinalResponse(message.text, finalText)) return message;
return redactConversationMessage({
...message,
text: finalText,
status: textOr(finalResponse.status, message.status),
updatedAt: textOr(finalResponse.updatedAt ?? finalResponse.createdAt, message.updatedAt)
});
}).filter(Boolean);
}
function isFoldedProgressBeforeFinalResponse(messageText, finalText) {
const text = textOr(messageText, "");
const final = textOr(finalText, "");
if (final.length < 80 || text.length <= final.length) return false;
const index = text.indexOf(final);
const foldedIndex = index > 0 ? index : collapseConversationWhitespace(text).indexOf(collapseConversationWhitespace(final));
if (foldedIndex <= 0) return false;
const prefix = text.slice(0, Math.min(foldedIndex, text.length)).trim();
if (prefix.length < 20) return false;
return /\bLet me (?:check|inspect|look)\b|\bI(?:'|)?ll (?:check|inspect|look)\b|我(?:先|来|再)?(?:确认|检查|查看|看看)|让我(?:确认|检查|查看|看看)|正在(?:确认|检查|查看)|当前工作区还没有|hwpod inspect/iu.test(prefix);
}
function collapseConversationWhitespace(value) {
return textOr(value, "").replace(/\s+/gu, " ").trim();
}
function normalizeFoldedProgressConversationMessages(messages) {
if (!Array.isArray(messages) || messages.length < 2) return messages;
const cleanAgentTexts = [];
return messages.map((message) => {
if (message?.role !== "agent") return message;
const text = textOr(message.text, "");
const replacementText = cleanAgentTexts.find((candidate) => isFoldedProgressBeforeFinalResponse(text, candidate));
const normalized = replacementText ? redactConversationMessage({ ...message, text: replacementText }) : message;
const normalizedText = textOr(normalized?.text, "");
if (normalizedText.length >= 80 && !cleanAgentTexts.includes(normalizedText) && !cleanAgentTexts.some((candidate) => isFoldedProgressBeforeFinalResponse(normalizedText, candidate))) {
cleanAgentTexts.push(normalizedText);
}
return normalized;
}).filter(Boolean);
}
function mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) {
const next = dedupeConversationMessages(nextMessages);
if (next.some((message) => message.role === "user")) return ensureTerminalUserMessage(next, context).slice(-50);
const existing = dedupeConversationMessages(existingMessages);
const merged = [...existing];
for (const message of next) {
const index = merged.findIndex((item) => sameConversationMessage(item, message));
if (index >= 0) merged[index] = mergeConversationMessage(merged[index], message);
else merged.push(message);
}
return dedupeConversationMessages(ensureTerminalUserMessage(merged, context)).slice(-50);
}
function sameConversationMessage(left = {}, right = {}) {
const leftId = textOr(left.id, "");
const rightId = textOr(right.id, "");
if (leftId && rightId && leftId === rightId) return true;
const leftTrace = safeTraceIdLocal(left.traceId);
const rightTrace = safeTraceIdLocal(right.traceId);
return Boolean(leftTrace && rightTrace && leftTrace === rightTrace && textOr(left.role, "") === textOr(right.role, ""));
}
function dedupeConversationMessages(messagesSource = []) {
const source = Array.isArray(messagesSource) ? messagesSource : [];
const merged = [];
for (const rawMessage of source) {
const message = redactConversationMessage(rawMessage);
if (!message) continue;
const index = merged.findIndex((item) => sameConversationMessage(item, message));
if (index >= 0) merged[index] = mergeConversationMessage(merged[index], message);
else merged.push(message);
}
return merged.slice(-50);
}
function mergeConversationMessage(existing = {}, next = {}) {
return pruneEmpty({
...existing,
...next,
title: textOr(next.title, existing.title),
text: textOr(next.text, existing.text),
runnerTrace: next.runnerTrace ?? existing.runnerTrace,
updatedAt: textOr(next.updatedAt, existing.updatedAt),
createdAt: textOr(next.createdAt, existing.createdAt)
});
}
function firstMessageTraceId(messagesSource) {
return safeTraceIdLocal(firstMessageField(messagesSource, "traceId")) || null;
}
function firstMessageField(messagesSource, key) {
if (!Array.isArray(messagesSource)) return null;
for (const message of messagesSource) {
const value = textOr(message?.[key], "");
if (value) return boundedText(value, 240);
}
return null;
}
function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; }
function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); }
function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); }
function defaultWorkspaceId(ownerUserId, projectId) { return `wsp_${sha256(`${ownerUserId}:${projectId}`).slice(0, 24)}`; }
function currentSessionIdFromAuth(auth) { return textOr(auth?.session?.id, null); }
function normalizeWorkspacePatch(body = {}, actor = null) {
const workspace = normalizeObject(body.workspace ?? body.workspaceJson ?? body.snapshot ?? body);
return {
...workspace,
selectedConversationId: textOr(body.selectedConversationId ?? body.conversationId ?? workspace.selectedConversationId, workspace.selectedConversationId ?? null),
selectedAgentSessionId: safeAgentSessionId(body.selectedAgentSessionId ?? body.sessionId ?? workspace.selectedAgentSessionId) || workspace.selectedAgentSessionId,
activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null,
providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null),
messages: Array.isArray(body.messages) ? dedupeConversationMessages(body.messages) : Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
};
}
function normalizeWorkspaceRecord(input = {}, existing = null, now = new Date().toISOString(), { create = false, replaceJson = false } = {}) {
const patch = normalizeObject(input.patch);
const workspaceJson = replaceJson ? patch : { ...normalizeObject(existing?.workspace), ...patch };
return {
id: textOr(input.workspaceId ?? input.id, existing?.id ?? (create ? defaultWorkspaceId(input.ownerUserId, input.projectId) : "")),
ownerUserId: textOr(input.ownerUserId, existing?.ownerUserId ?? ""),
projectId: textOr(input.projectId, existing?.projectId ?? "prj_hwpod_workbench"),
name: textOr(input.name, existing?.name ?? "Default Workbench"),
status: input.status === "archived" ? "archived" : existing?.status ?? "active",
isDefault: input.isDefault === false ? false : existing?.isDefault ?? true,
selectedConversationId: nullableText(input.selectedConversationId, existing?.selectedConversationId),
selectedAgentSessionId: nullableText(input.selectedAgentSessionId, existing?.selectedAgentSessionId),
activeTraceId: nullableText(input.activeTraceId, existing?.activeTraceId),
providerProfile: nullableText(input.providerProfile, existing?.providerProfile),
workspace: workspaceJson,
revision: (Number(existing?.revision) || 0) + (existing ? 1 : 1),
updatedBySessionId: nullableText(input.updatedBySessionId, existing?.updatedBySessionId),
updatedByClient: nullableText(input.updatedByClient, existing?.updatedByClient),
createdAt: existing?.createdAt ?? now,
updatedAt: now
};
}
function nullableText(value, fallback = null) {
if (value === null) return null;
const text = textOr(value, "");
return text || fallback || null;
}
function publicWorkbenchWorkspace(workspace, { conversation = null } = {}) {
if (!workspace) return null;
return {
workspaceId: workspace.id,
ownerUserId: workspace.ownerUserId,
projectId: workspace.projectId,
name: workspace.name,
status: workspace.status,
isDefault: workspace.isDefault,
revision: workspace.revision,
selectedConversationId: workspace.selectedConversationId,
selectedAgentSessionId: workspace.selectedAgentSessionId,
activeTraceId: workspace.activeTraceId,
providerProfile: workspace.providerProfile,
selectedConversation: conversation,
workspace: redactedWorkspaceJson(workspace.workspace),
updatedBySessionId: workspace.updatedBySessionId,
updatedByClient: workspace.updatedByClient,
createdAt: workspace.createdAt,
updatedAt: workspace.updatedAt,
valuesRedacted: true,
secretMaterialStored: false
};
}
function redactedWorkspaceJson(value = {}) {
const workspace = normalizeObject(value);
return pruneEmpty({
selectedConversationId: textOr(workspace.selectedConversationId, ""),
selectedAgentSessionId: textOr(workspace.selectedAgentSessionId, ""),
activeTraceId: textOr(workspace.activeTraceId, ""),
previousActiveTraceId: textOr(workspace.previousActiveTraceId, ""),
providerProfile: textOr(workspace.providerProfile, ""),
sessionStatus: textOr(workspace.sessionStatus, ""),
lastTraceId: textOr(workspace.lastTraceId, ""),
workspaceRevisionConflict: workspace.workspaceRevisionConflict === true,
expectedWorkspaceRevision: Number.isFinite(Number(workspace.expectedWorkspaceRevision)) ? Number(workspace.expectedWorkspaceRevision) : undefined,
staleContinuationCleared: workspace.staleContinuationCleared === true,
staleContinuationReason: textOr(workspace.staleContinuationReason, ""),
staleConversationId: textOr(workspace.staleConversationId, ""),
staleAgentSessionId: textOr(workspace.staleAgentSessionId, ""),
staleThreadId: textOr(workspace.staleThreadId, ""),
recoveryAction: textOr(workspace.recoveryAction, ""),
messages: Array.isArray(workspace.messages) ? dedupeConversationMessages(workspace.messages) : undefined,
actor: workspace.actor && typeof workspace.actor === "object" ? publicActor(workspace.actor) : undefined,
updatedAt: textOr(workspace.updatedAt, ""),
resetAt: textOr(workspace.resetAt, ""),
secretMaterialStored: false,
valuesRedacted: true
});
}
function workspaceParams(workspace) {
return [workspace.id, workspace.ownerUserId, workspace.projectId, workspace.name, workspace.status, workspace.isDefault, workspace.selectedConversationId, workspace.selectedAgentSessionId, workspace.activeTraceId, workspace.providerProfile, stableJson(workspace.workspace ?? {}), workspace.revision, workspace.updatedBySessionId, workspace.updatedByClient, workspace.createdAt, workspace.updatedAt];
}
function normalizeConversationSnapshot(body = {}, actor = null) {
const snapshot = normalizeObject(body.snapshot ?? body);
const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages;
const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages;
const messages = dedupeConversationMessages(messagesSource);
const chatMessages = Array.isArray(chatMessagesSource) ? dedupeConversationMessages(chatMessagesSource) : undefined;
return {
...snapshot,
messages,
chatMessages,
messageCount: resolveSnapshotMessageCount(messages, snapshot.messageCount),
firstUserMessagePreview: firstUserPreviewFromMessages(messages) ?? snapshot.firstUserMessagePreview ?? null,
actor: actor ? publicActor(actor) : undefined,
secretMaterialStored: false,
valuesRedacted: true
};
}
function resolveSnapshotMessageCount(messagesSource, existingCount) {
const explicit = numberOrNull(existingCount);
const fromSource = Array.isArray(messagesSource) ? messagesSource.length : null;
if (fromSource !== null) return fromSource;
return explicit ?? fromSource ?? 0;
}
function firstUserPreviewFromMessages(messagesSource) {
if (!Array.isArray(messagesSource)) return null;
for (const message of messagesSource) {
if (!message || typeof message !== "object") continue;
if (String(message.role ?? "").toLowerCase() !== "user") continue;
const text = textOr(message.text ?? message.title ?? message.content, "");
if (text) return boundedText(text, 240);
}
return null;
}
function numberOrNull(value) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function isTerminalCodeAgentResult(result = {}) {
const status = textOr(result.status, "").toLowerCase();
const terminalStatus = textOr(result.agentRun?.terminalStatus, "").toLowerCase();
return [status, terminalStatus].some((value) => ["completed", "failed", "blocked", "canceled", "cancelled", "timeout", "error"].includes(value));
}
function isThreadResumeFailedResult(result = {}) {
const values = [
result.error?.code,
result.error?.category,
result.blocker?.code,
result.blocker?.category,
result.providerTrace?.failureKind,
result.agentRun?.providerTrace?.failureKind,
result.agentRun?.failureKind
].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-"));
return values.some((value) => value === "thread-resume-failed")
|| /no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? ""));
}
function threadResumeFailureThreadId(result = {}) {
return boundedText(textOr(
result.providerTrace?.threadId
?? result.agentRun?.providerTrace?.threadId
?? result.session?.threadId
?? result.sessionReuse?.threadId
?? result.threadId,
""
), 240) || null;
}
function terminalWorkbenchSessionStatus(result = {}) {
if (isThreadResumeFailedResult(result)) return "failed";
const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase();
if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus;
const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase();
if (status === "completed") return "idle";
if (status === "cancelled") return "canceled";
return status || "idle";
}
function canActorReadWorkspaceTrace(result = {}, actor = null) {
const ownerUserId = textOr(result.ownerUserId, "");
if (!ownerUserId || actor?.role === "admin") return true;
return ownerUserId === actor?.id;
}
function conversationNeedsTerminalRepair(conversation, traceId) {
if (!conversation || !safeTraceIdLocal(traceId)) return false;
const status = textOr(conversation.status, "").toLowerCase();
if (["running", "busy", "pending", "active"].includes(status)) return true;
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null;
const latestAgent = latestAgentConversationMessage(messages);
if (!traceMessage) return Boolean(latestAgent && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId));
if (latestAgent?.traceId && latestAgent.traceId !== traceId && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true;
const messageStatus = textOr(traceMessage.status, "").toLowerCase();
return ["", "running", "busy", "pending", "active"].includes(messageStatus)
|| isLegacyFinalResponseFallbackText(traceMessage.text);
}
function terminalConversationRepairTraceId(conversation) {
if (!conversation) return "";
const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId);
if (lastTraceId && conversationNeedsTerminalRepair(conversation, lastTraceId)) return lastTraceId;
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const traceId = safeTraceIdLocal(message?.traceId);
if (message?.role === "agent" && traceId && conversationNeedsTerminalRepair(conversation, traceId)) return traceId;
}
return "";
}
function terminalConversationFallbackRepairTraceId(conversation) {
if (!conversation) return "";
const messages = Array.isArray(conversation.messages) ? conversation.messages : [];
const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId);
const lastTraceMessage = messages.find((message) => message?.traceId === lastTraceId && message?.role === "agent") ?? null;
const latestAgent = latestAgentConversationMessage(messages);
if (lastTraceId && latestAgent?.traceId && latestAgent.traceId !== lastTraceId) return lastTraceId;
if (lastTraceId && latestAgent && !lastTraceMessage) return lastTraceId;
if (lastTraceId && isLegacyFinalResponseFallbackText(lastTraceMessage?.text)) return lastTraceId;
for (let index = messages.length - 1; index >= 0; index -= 1) {
const message = messages[index];
const traceId = safeTraceIdLocal(message?.traceId);
if (message?.role === "agent" && traceId && isLegacyFinalResponseFallbackText(message.text)) return traceId;
}
return "";
}
function isLegacyFinalResponseFallbackText(value) {
return textOr(value, "").trim() === "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。";
}
function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) {
const baseMessages = dedupeConversationMessages(existingMessages);
const traceId = safeTraceIdLocal(result.traceId ?? context.traceId);
const terminalStatus = terminalWorkbenchSessionStatus(result);
const assistantText = textOr(result.reply?.content ?? result.assistantText ?? result.message?.content ?? result.error?.message ?? result.userMessage, "");
const now = textOr(context.now, new Date().toISOString());
const firstUserPreview = firstUserPreviewFromMessages(baseMessages) ?? textOr(context.firstUserMessagePreview, null);
const messages = dedupeConversationMessages(ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview }));
const runnerTrace = terminalRunnerTraceForConversationMessage(result, { ...context, traceId, terminalStatus });
const next = messages.map((message) => {
if (traceId && message.traceId === traceId && message.role === "agent") {
return redactConversationMessage({
...message,
text: assistantText || message.text,
status: terminalStatus,
conversationId: context.conversationId ?? message.conversationId,
sessionId: context.sessionId ?? message.sessionId,
threadId: context.threadId ?? message.threadId,
runnerTrace: runnerTrace ?? message.runnerTrace,
updatedAt: now
});
}
return message;
}).filter(Boolean);
let addedAgentMessage = false;
if (traceId && !next.some((message) => message.traceId === traceId && message.role === "agent")) {
next.push(redactConversationMessage({
id: result.reply?.messageId ?? result.messageId ?? `msg_${traceId.slice(4)}`,
role: "agent",
title: "Code Agent result",
text: assistantText,
status: terminalStatus,
traceId,
conversationId: context.conversationId,
sessionId: context.sessionId,
threadId: context.threadId,
runnerTrace,
createdAt: result.reply?.createdAt ?? result.createdAt ?? now,
updatedAt: now
}));
addedAgentMessage = true;
}
const finalMessages = dedupeConversationMessages(next).slice(-50);
return {
messages: finalMessages,
messageCount: finalMessages.length,
firstUserMessagePreview: firstUserPreviewFromMessages(finalMessages) ?? firstUserPreview
};
}
function ensureTerminalUserMessage(messages = [], context = {}) {
if (messages.some((message) => message?.role === "user")) return messages;
const userText = textOr(context.firstUserPreview, "");
if (!userText) return messages;
const traceId = safeTraceIdLocal(context.traceId);
const userMessage = redactConversationMessage({
id: traceId ? `msg_${traceId.slice(4)}_user` : `msg_terminal_user_${messages.length}`,
role: "user",
title: "用户",
text: userText,
status: "sent",
traceId,
conversationId: context.conversationId,
sessionId: context.sessionId,
threadId: context.threadId,
createdAt: context.now,
updatedAt: context.now
});
return userMessage ? [userMessage, ...messages] : messages;
}
function terminalRunnerTraceForConversationMessage(result = {}, context = {}) {
const source = result.runnerTrace && typeof result.runnerTrace === "object" ? result.runnerTrace : null;
if (!source) return null;
const events = Array.isArray(source.events) ? source.events : [];
const lastEvent = source.lastEvent && typeof source.lastEvent === "object" ? source.lastEvent : events.at(-1);
return pruneEmpty({
traceId: safeTraceIdLocal(source.traceId ?? result.traceId ?? context.traceId) || undefined,
status: textOr(source.status ?? result.status ?? result.agentRun?.terminalStatus ?? context.terminalStatus, ""),
sessionId: safeAgentSessionId(source.sessionId ?? result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId ?? context.sessionId) || undefined,
threadId: textOr(source.threadId ?? result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? context.threadId, ""),
eventCount: numberOrNull(source.eventCount ?? result.traceSummary?.sourceEventCount ?? result.traceSummary?.eventCount) ?? events.length,
eventsCompacted: source.eventsCompacted === true || events.length === 0,
fullTraceLoaded: false,
lastEvent,
traceStatus: textOr(result.traceStatus, ""),
fallback: redactTraceFallback(result.fallback),
finalResponse: redactTraceFinalResponse(result.finalResponse),
traceSummary: redactTraceSummary(result.traceSummary)
});
}
function redactConversationMessage(message) {
if (!message || typeof message !== "object") return null;
const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null;
return pruneEmpty({
id: textOr(message.id, ""),
role: textOr(message.role, ""),
title: textOr(message.title, ""),
text: boundedText(message.text, 12000),
status: textOr(message.status, ""),
traceId: textOr(message.traceId, ""),
conversationId: textOr(message.conversationId, ""),
sessionId: textOr(message.sessionId, ""),
threadId: textOr(message.threadId, ""),
retryOf: textOr(message.retryOf, ""),
createdAt: textOr(message.createdAt, ""),
updatedAt: textOr(message.updatedAt, ""),
runnerTrace: runnerTrace ? pruneEmpty({
traceId: runnerTrace.traceId,
status: runnerTrace.status,
sessionId: runnerTrace.sessionId,
threadId: runnerTrace.threadId,
eventCount: runnerTrace.eventCount,
eventsCompacted: runnerTrace.eventsCompacted === true,
fullTraceLoaded: runnerTrace.fullTraceLoaded === true,
lastEvent: runnerTrace.lastEvent,
traceStatus: textOr(runnerTrace.traceStatus, ""),
fallback: redactTraceFallback(runnerTrace.fallback),
finalResponse: redactTraceFinalResponse(runnerTrace.finalResponse),
traceSummary: redactTraceSummary(runnerTrace.traceSummary)
}) : undefined
});
}
function redactTraceFallback(value) {
const fallback = normalizeObject(value);
if (Object.keys(fallback).length === 0) return undefined;
return pruneEmpty({
available: fallback.available === true,
source: textOr(fallback.source, ""),
conversationId: textOr(fallback.conversationId, ""),
sessionId: textOr(fallback.sessionId, ""),
threadId: textOr(fallback.threadId, ""),
agentRun: redactAgentRunSummary(fallback.agentRun),
finalResponse: redactTraceFinalResponse(fallback.finalResponse),
traceSummary: redactTraceSummary(fallback.traceSummary)
});
}
function redactTraceFinalResponse(value) {
const response = normalizeObject(value);
if (Object.keys(response).length === 0) return undefined;
return pruneEmpty({
role: textOr(response.role, ""),
status: textOr(response.status, ""),
messageId: textOr(response.messageId, ""),
text: boundedText(response.text ?? response.content ?? response.message, 12000),
textPreview: boundedText(response.textPreview, 1200),
textChars: numberOrNull(response.textChars)
});
}
function redactTraceSummary(value) {
const summary = normalizeObject(value);
if (Object.keys(summary).length === 0) return undefined;
return pruneEmpty({
traceId: textOr(summary.traceId, ""),
source: textOr(summary.source, ""),
terminalStatus: textOr(summary.terminalStatus, ""),
sourceEventCount: numberOrNull(summary.sourceEventCount ?? summary.eventCount),
noiseEventCount: numberOrNull(summary.noiseEventCount),
omittedNoiseCount: numberOrNull(summary.omittedNoiseCount),
lastEventLabel: textOr(summary.lastEventLabel, ""),
updatedAt: textOr(summary.updatedAt, ""),
finalAssistantRow: redactTraceFinalResponse(summary.finalAssistantRow),
agentRun: redactAgentRunSummary(summary.agentRun)
});
}
function redactAgentRunSummary(value) {
const agentRun = normalizeObject(value);
if (Object.keys(agentRun).length === 0) return undefined;
return pruneEmpty({
adapter: textOr(agentRun.adapter, ""),
managerUrl: textOr(agentRun.managerUrl, ""),
backendProfile: textOr(agentRun.backendProfile, ""),
providerId: textOr(agentRun.providerId, ""),
runId: textOr(agentRun.runId, ""),
commandId: textOr(agentRun.commandId, ""),
attemptId: textOr(agentRun.attemptId, ""),
runnerId: textOr(agentRun.runnerId, ""),
runnerJobId: textOr(agentRun.runnerJobId, ""),
jobName: textOr(agentRun.jobName, ""),
namespace: textOr(agentRun.namespace, ""),
status: textOr(agentRun.status, ""),
runStatus: textOr(agentRun.runStatus, ""),
commandState: textOr(agentRun.commandState, ""),
terminalStatus: textOr(agentRun.terminalStatus, ""),
lastSeq: numberOrNull(agentRun.lastSeq),
eventStartSeq: numberOrNull(agentRun.eventStartSeq ?? agentRun.commandStartSeq ?? agentRun.startSeq),
sessionId: textOr(agentRun.sessionId, ""),
conversationId: textOr(agentRun.conversationId, ""),
threadId: textOr(agentRun.threadId, ""),
traceId: textOr(agentRun.traceId, ""),
reuseEligible: typeof agentRun.reuseEligible === "boolean" ? agentRun.reuseEligible : undefined
});
}
function conversationsFromAgentSessions(sessions = []) {
const byConversation = new Map();
for (const session of sessions) {
const conversationId = textOr(session.conversationId, "");
if (!conversationId) continue;
const previous = byConversation.get(conversationId);
if (previous && String(previous.updatedAt ?? "") >= String(session.updatedAt ?? "")) continue;
byConversation.set(conversationId, publicAgentConversation(session));
}
return [...byConversation.values()].sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? "")));
}
function publicAgentConversation(session) {
const snapshot = normalizeObject(session.session);
const rawMessages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : [];
const messages = dedupeConversationMessages(ensureTerminalUserMessage(rawMessages, {
traceId: session.lastTraceId ?? snapshot.lastTraceId,
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
now: session.updatedAt,
firstUserPreview: snapshot.firstUserMessagePreview
}));
const displayMessages = normalizeFoldedProgressConversationMessages(normalizeFinalResponseConversationMessages(messages, snapshot.finalResponse));
const status = resolvedConversationStatus(session.status, snapshot, displayMessages);
const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, displayMessages, snapshot);
return {
conversationId: session.conversationId,
sessionId: session.id,
threadId: session.threadId,
status,
projectId: session.projectId,
agentId: session.agentId,
ownerUserId: session.ownerUserId,
lastTraceId,
updatedAt: session.updatedAt,
startedAt: session.startedAt,
endedAt: session.endedAt,
session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status }),
messages: displayMessages,
messageCount: displayMessages.length,
firstUserMessagePreview: textOr(snapshot.firstUserMessagePreview, null),
snapshot: pruneEmpty({
sessionStatus: snapshot.sessionStatus,
source: snapshot.source,
updatedAt: snapshot.updatedAt,
valuesRedacted: snapshot.valuesRedacted !== false
}),
valuesRedacted: true
};
}
function resolvedConversationStatus(status, snapshot, messages = []) {
const stored = textOr(status, "").toLowerCase();
const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase();
const latestAgent = latestAgentConversationMessage(messages);
const latestStatus = textOr(latestAgent?.status, "").toLowerCase();
if (["running", "busy", "pending", "active"].includes(stored)) {
if (snapshotStatus && !["running", "busy", "pending", "active"].includes(snapshotStatus)) return snapshotStatus === "cancelled" ? "canceled" : snapshotStatus;
if (latestStatus && !["running", "busy", "pending", "active"].includes(latestStatus)) return latestStatus === "cancelled" ? "canceled" : latestStatus;
}
return stored || snapshotStatus || latestStatus || "idle";
}
function resolvedConversationLastTraceId(storedTraceId, status, messages = [], snapshot = {}) {
const latestTraceId = textOr(latestAgentConversationMessage(messages)?.traceId, "");
const snapshotTraceId = textOr(snapshot.lastTraceId, "");
const stored = textOr(storedTraceId, "");
const snapshotStatus = textOr(snapshot.sessionStatus ?? snapshot.status, "").toLowerCase();
if (stored && snapshotStatus && !["running", "busy", "pending", "active"].includes(snapshotStatus)) return stored;
if (!["running", "busy", "pending", "active"].includes(textOr(status, "").toLowerCase())) {
return latestTraceId || snapshotTraceId || stored;
}
return stored || latestTraceId || snapshotTraceId;
}
function latestAgentConversationMessage(messages = []) {
if (!Array.isArray(messages)) return null;
for (const message of [...messages].reverse()) {
if (String(message?.role ?? "").toLowerCase() === "agent") return message;
}
return null;
}
function boundedText(value, maxBytes) {
const text = String(value ?? "");
const buffer = Buffer.from(text, "utf8");
return buffer.length > maxBytes ? buffer.subarray(0, maxBytes).toString("utf8") : text;
}
function pruneEmpty(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); }
function safeReturnTo(value) {
const text = textOr(value, "/workbench").trim() || "/workbench";
if (!text.startsWith("/") || text.startsWith("//") || text.includes("\\") || /[\u0000-\u001f\u007f]/u.test(text)) return "/workbench";
try {
const url = new URL(text, "https://hwlab.local");
if (url.origin !== "https://hwlab.local") return "/workbench";
return `${url.pathname}${url.search}${url.hash}` || "/workbench";
} catch {
return "/workbench";
}
}
async function verifyOidcIdToken(idToken, { issuer, clientId, nonce, now, fetchImpl }) {
const parts = String(idToken ?? "").split(".");
if (parts.length !== 3) return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token is missing or malformed" };
const header = parseJson(Buffer.from(base64UrlToBase64(parts[0]), "base64").toString("utf8"), null);
const claims = parseJson(Buffer.from(base64UrlToBase64(parts[1]), "base64").toString("utf8"), null);
if (!header || typeof header !== "object") return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token header is invalid" };
if (!claims || typeof claims !== "object") return { ok: false, code: "oidc_id_token_invalid", message: "OIDC ID token payload is invalid" };
if (textOr(header.alg, "") !== "RS256") return { ok: false, code: "oidc_id_token_alg_invalid", message: "OIDC ID token must be signed with RS256" };
const kid = textOr(header.kid, "");
if (!kid) return { ok: false, code: "oidc_id_token_kid_missing", message: "OIDC ID token key id is missing" };
const issuerBase = issuer.replace(/\/$/, "");
const keyResult = await fetchOidcJwksKey({ issuer: issuerBase, kid, fetchImpl });
if (!keyResult.ok) return keyResult;
let signatureOk = false;
try {
const key = createPublicKey({ key: keyResult.jwk, format: "jwk" });
signatureOk = verifySignature("RSA-SHA256", Buffer.from(`${parts[0]}.${parts[1]}`), key, Buffer.from(base64UrlToBase64(parts[2]), "base64"));
} catch (error) {
return { ok: false, code: "oidc_id_token_signature_error", message: error?.message ?? "OIDC ID token signature verification failed" };
}
if (!signatureOk) return { ok: false, code: "oidc_id_token_signature_invalid", message: "OIDC ID token signature is invalid" };
if (textOr(claims.iss, "") !== issuerBase) return { ok: false, code: "oidc_issuer_mismatch", message: "OIDC ID token issuer mismatch" };
const aud = Array.isArray(claims.aud) ? claims.aud.map(String) : [textOr(claims.aud, "")];
if (!aud.includes(clientId)) return { ok: false, code: "oidc_audience_mismatch", message: "OIDC ID token audience mismatch" };
if (aud.length > 1 && textOr(claims.azp, "") !== clientId) return { ok: false, code: "oidc_authorized_party_mismatch", message: "OIDC ID token authorized party mismatch" };
if (!textOr(claims.sub, "")) return { ok: false, code: "oidc_subject_missing", message: "OIDC ID token subject is missing" };
if (textOr(claims.nonce, "") !== textOr(nonce, "")) return { ok: false, code: "oidc_nonce_mismatch", message: "OIDC ID token nonce mismatch" };
const nowSeconds = Math.floor(Date.parse(now) / 1000);
const exp = Number(claims.exp);
const iat = Number(claims.iat);
const nbf = Number(claims.nbf);
if (!Number.isFinite(exp)) return { ok: false, code: "oidc_id_token_exp_missing", message: "OIDC ID token expiration is missing" };
if (exp <= nowSeconds) return { ok: false, code: "oidc_id_token_expired", message: "OIDC ID token is expired" };
if (!Number.isFinite(iat)) return { ok: false, code: "oidc_id_token_iat_missing", message: "OIDC ID token issued-at is missing" };
if (iat > nowSeconds + 300) return { ok: false, code: "oidc_id_token_iat_invalid", message: "OIDC ID token issued-at is in the future" };
if (Number.isFinite(nbf) && nbf > nowSeconds + 300) return { ok: false, code: "oidc_id_token_nbf_invalid", message: "OIDC ID token not-before is in the future" };
return { ok: true, claims };
}
async function fetchOidcJwksKey({ issuer, kid, fetchImpl }) {
try {
const response = await (fetchImpl ?? fetch)(`${issuer}/protocol/openid-connect/certs`, { headers: { accept: "application/json" } });
const text = await response.text();
const json = parseJson(text, {});
if (response.status >= 400) return { ok: false, code: "oidc_jwks_fetch_failed", message: textOr(json.error_description, text) };
const key = (Array.isArray(json.keys) ? json.keys : []).find((item) => textOr(item?.kid, "") === kid && textOr(item?.kty, "") === "RSA");
if (!key) return { ok: false, code: "oidc_jwks_key_not_found", message: "OIDC signing key was not found in JWKS" };
return { ok: true, jwk: key };
} catch (error) {
return { ok: false, code: "oidc_jwks_fetch_error", message: error?.message ?? String(error) };
}
}
function base64UrlToBase64(value) {
const text = String(value ?? "").replace(/-/gu, "+").replace(/_/gu, "/");
return text.padEnd(Math.ceil(text.length / 4) * 4, "=");
}
function sessionCookieFromRequest(request) { const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; }
function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); }
function setSessionCookie(response, token, maxAge, request, env = process.env) { response.setHeader("set-cookie", buildSessionCookie(token, maxAge, { secure: shouldUseSecureSessionCookie(request, env) })); }
function clearSessionCookie(response, request, env = process.env) { response.setHeader("set-cookie", buildClearSessionCookie({ secure: shouldUseSecureSessionCookie(request, env) })); }
function shouldUseSecureSessionCookie(request, env = process.env) {
const forced = textOr(env.HWLAB_SESSION_COOKIE_SECURE, "").toLowerCase();
if (["1", "true", "yes", "on", "secure"].includes(forced)) return true;
if (["0", "false", "no", "off", "insecure"].includes(forced)) return false;
const forwardedProto = textOr(getHeader(request, "x-forwarded-proto"), "").split(",")[0]?.trim().toLowerCase();
if (forwardedProto) return forwardedProto === "https";
const forwarded = textOr(getHeader(request, "forwarded"), "");
if (/\bproto=https\b/iu.test(forwarded)) return true;
if (/\bproto=http\b/iu.test(forwarded)) return false;
return false;
}
function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; }
function accessWriteErrorPayload(result = {}) {
const error = result.error ?? {};
const code = textOr(error.code, "openfga_write_failed");
const message = textOr(error.message, "OpenFGA authorization write failed; permission cache was not updated");
return { ...errorPayload(code, message, result.status ?? 503), result };
}
function sendAccessError(response, error) { const status = error?.statusCode ?? 500; sendJson(response, status, errorPayload(error?.code ?? "access_control_error", error?.message ?? "Access control request failed", status)); }
function pgUser(row) { return row ? { id: row.id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.created_at, updatedAt: row.updated_at } : null; }
function pgUserWithOIDC(row) { const user = pgUser(row); if (!user) return null; return { ...user, authProvider: textOr(row.auth_provider, "local"), keycloakIssuer: textOr(row.keycloak_issuer, "") || null, keycloakSub: textOr(row.keycloak_sub, "") || null, email: textOr(row.email, "") || null, lastLoginAt: textOr(row.last_login_at, "") || null }; }
function pgApiKey(row) { if (!row) return null; return { id: row.id, userId: row.user_id, name: textOr(row.name, API_KEY_DEFAULT_NAME), keyPrefix: row.key_prefix, keyHash: row.key_hash ?? null, displaySecret: row.display_secret ?? null, scopes: parseJson(row.scopes_json, []), status: textOr(row.status, "active"), createdAt: row.created_at, lastUsedAt: row.last_used_at ?? null, revokedAt: row.revoked_at ?? null }; }
function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; }
function pgAgentSession(row) { return row ? { id: row.id, projectId: row.project_id, agentId: row.agent_id, status: row.status, startedAt: row.started_at, endedAt: row.ended_at, ownerUserId: row.owner_user_id, conversationId: row.conversation_id, threadId: row.thread_id, lastTraceId: row.last_trace_id, session: parseJson(row.session_json, {}), updatedAt: row.updated_at } : null; }
function pgWorkspace(row) { return row ? { id: row.id, ownerUserId: row.owner_user_id, projectId: row.project_id, name: row.name, status: row.status, isDefault: row.is_default !== false, selectedConversationId: row.selected_conversation_id, selectedAgentSessionId: row.selected_agent_session_id, activeTraceId: row.active_trace_id, providerProfile: row.provider_profile, workspace: parseJson(row.workspace_json, {}), revision: Number(row.revision ?? 1), updatedBySessionId: row.updated_by_session_id, updatedByClient: row.updated_by_client, createdAt: row.created_at, updatedAt: row.updated_at } : null; }
function pgAccessTuple(row) { return row ? { userId: row.user_id, relation: row.relation, object: row.object, createdByAdminId: row.created_by_admin_id, createdAt: row.created_at } : null; }
function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } }
function normalizeBlocker(value) { return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null; }