2725 lines
156 KiB
TypeScript
2725 lines
156 KiB
TypeScript
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";
|
|
import { createUserBillingClient } from "./user-billing-client.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 AUTH_METHOD_USER_BILLING_API_KEY = "user-billing-api-key";
|
|
const AUTH_METHOD_USER_BILLING_SESSION = "user-billing-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, userBillingClient = 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.userBilling = userBillingClient ?? createUserBillingClient({ env, fetchImpl });
|
|
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 bearerToken = bearerTokenFromRequest(request);
|
|
if (bearerToken && this.userBilling?.configured) {
|
|
const delegated = await this.authenticateUserBillingToken(bearerToken);
|
|
if (delegated.ok || !isApiKeySecret(bearerToken)) return delegated;
|
|
}
|
|
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 authenticateUserBillingToken(secret) {
|
|
const result = await this.userBilling.introspect(secret);
|
|
if (!result.ok) {
|
|
return errorPayload(result.error?.code ?? "user_billing_introspect_failed", result.error?.message ?? "user-billing introspection failed", result.status ?? 503);
|
|
}
|
|
if (result.body?.active !== true || !result.body?.principal?.userId) {
|
|
return errorPayload("api_key_invalid", "API key is missing or invalid", 401);
|
|
}
|
|
const principal = result.body.principal;
|
|
const actor = userBillingActor(principal);
|
|
const synced = await this.store.upsertExternalUser?.({ ...actor, authProvider: "user-billing", email: principal.email, now: this.now() }) ?? actor;
|
|
const authMethod = principal.authType === "session" ? AUTH_METHOD_USER_BILLING_SESSION : AUTH_METHOD_USER_BILLING_API_KEY;
|
|
return {
|
|
ok: true,
|
|
actor: synced,
|
|
session: {
|
|
id: principal.keyId || `ub_${sha256(`${principal.userId}:${authMethod}`).slice(0, 24)}`,
|
|
userId: synced.id,
|
|
keyPrefix: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? redactedTokenPrefix(secret) : null,
|
|
name: "user-billing",
|
|
createdAt: null,
|
|
lastSeenAt: this.now(),
|
|
expiresAt: null,
|
|
revoked: false,
|
|
tokenSource: authMethod,
|
|
valuesRedacted: true
|
|
},
|
|
apiKey: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? { id: principal.keyId ?? null, keyPrefix: redactedTokenPrefix(secret), name: "user-billing", displaySecret: secret } : null,
|
|
authMethod,
|
|
userBilling: { active: true, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true }
|
|
};
|
|
}
|
|
|
|
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
|
|
? 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 = terminalConversationRepairTraceId(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 upsertExternalUser(input = {}) {
|
|
const now = input.now ?? this.now();
|
|
const id = textOr(input.id, "");
|
|
if (!id) return null;
|
|
const existing = this.users.get(id) ?? null;
|
|
const user = {
|
|
id,
|
|
username: textOr(input.username, existing?.username ?? id),
|
|
displayName: textOr(input.displayName, existing?.displayName ?? input.username ?? id),
|
|
role: input.role === "admin" ? "admin" : "user",
|
|
status: input.status === "disabled" ? "disabled" : "active",
|
|
passwordHash: existing?.passwordHash ?? null,
|
|
authProvider: textOr(input.authProvider, existing?.authProvider ?? "external"),
|
|
keycloakIssuer: existing?.keycloakIssuer ?? null,
|
|
keycloakSub: existing?.keycloakSub ?? null,
|
|
email: textOr(input.email, existing?.email ?? "") || null,
|
|
lastLoginAt: now,
|
|
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 upsertExternalUser(input = {}) {
|
|
await this.ensureSchema();
|
|
const now = input.now ?? this.now();
|
|
const id = textOr(input.id, "");
|
|
if (!id) return null;
|
|
const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,NULL,$6,$7,$8,$8,$8) ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, 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,
|
|
textOr(input.username, id),
|
|
textOr(input.displayName, input.username ?? id),
|
|
input.role === "admin" ? "admin" : "user",
|
|
input.status === "disabled" ? "disabled" : "active",
|
|
textOr(input.authProvider, "external"),
|
|
textOr(input.email, "") || null,
|
|
now
|
|
]);
|
|
return pgUserWithOIDC(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 token = bearerTokenFromRequest(request);
|
|
return isApiKeySecret(token) ? token : "";
|
|
}
|
|
function bearerTokenFromRequest(request) {
|
|
const auth = getHeader(request, "authorization");
|
|
if (!/^Bearer\s+/iu.test(String(auth ?? ""))) return "";
|
|
return String(auth).replace(/^Bearer\s+/iu, "").trim();
|
|
}
|
|
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 redactedTokenPrefix(value) { const token = textOr(value, ""); return token ? `${token.slice(0, Math.min(token.length, 12))}...` : ""; }
|
|
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, AUTH_METHOD_USER_BILLING_API_KEY, AUTH_METHOD_USER_BILLING_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION;
|
|
}
|
|
function userBillingActor(principal = {}) {
|
|
const id = textOr(principal.userId, "");
|
|
return {
|
|
id,
|
|
username: localUserBillingUsername(id),
|
|
displayName: textOr(principal.displayName, principal.username ?? principal.email ?? principal.userId),
|
|
role: principal.role === "admin" ? "admin" : "user",
|
|
status: "active"
|
|
};
|
|
}
|
|
function localUserBillingUsername(userId) {
|
|
const source = textOr(userId, "external");
|
|
const safe = source.replace(/[^A-Za-z0-9_.:-]+/gu, "_").slice(0, 48) || "external";
|
|
return `ub_${safe}_${sha256(source).slice(0, 12)}`;
|
|
}
|
|
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 = 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) return message;
|
|
return redactConversationMessage({
|
|
...message,
|
|
text: finalText,
|
|
status: textOr(finalResponse.status, message.status),
|
|
updatedAt: textOr(finalResponse.updatedAt ?? finalResponse.createdAt, message.updatedAt)
|
|
});
|
|
}).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);
|
|
}
|
|
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 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, ""),
|
|
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, ""),
|
|
finalResponse: redactTraceFinalResponse(runnerTrace.finalResponse),
|
|
traceSummary: redactTraceSummary(runnerTrace.traceSummary)
|
|
}) : undefined
|
|
});
|
|
}
|
|
function redactTraceFinalResponse(value) {
|
|
const response = normalizeObject(value);
|
|
if (Object.keys(response).length === 0) return undefined;
|
|
return pruneEmpty({
|
|
traceId: textOr(response.traceId, ""),
|
|
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 = 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; }
|