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 { DEVICE_POD_RELATIONS, HWLAB_TOOL_IDS, createOpenFgaAuthorizer, openFgaObject, parseOpenFgaObject } from "./openfga-authorization.ts"; import { getHeader, readBody, sendJson, sendRedirect, truthyFlag } from "./server-http-utils.ts"; const SESSION_COOKIE = "hwlab_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24; const SESSION_COOKIE_SAMESITE = "Lax"; function buildSessionCookie(token, maxAge, { secure = false } = {}) { return `${SESSION_COOKIE}=${encodeURIComponent(token)}; ${sessionCookieAttributes(maxAge, { secure })}`; } function buildClearSessionCookie({ secure = false } = {}) { return `${SESSION_COOKIE}=; ${sessionCookieAttributes(0, { secure })}`; } function sessionCookieAttributes(maxAge, { secure = false } = {}) { return ["Path=/", "HttpOnly", secure ? "Secure" : "", `SameSite=${SESSION_COOKIE_SAMESITE}`, `Max-Age=${maxAge}`] .filter(Boolean) .join("; "); } const DEVICE_POD_API_KEY_HEADER = "x-hwlab-device-pod-api-key"; const API_KEY_PREFIX = "hwl_live_"; const API_KEY_RANDOM_BYTES = 32; const API_KEY_DEFAULT_NAME = "Default 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_DEVICE_POD_API_KEY = "device-pod-api-key"; const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1"; const DEVICE_JOB_INTENTS = new Set([ "workspace.bootsharp", "workspace.ls", "workspace.cat", "workspace.rg", "workspace.apply-patch", "workspace.put", "workspace.rm", "workspace.rmdir", "workspace.keil", "workspace.build", "debug.status", "debug.chip-id", "debug.download", "debug.reset", "workspace.evidence", "debug.evidence", "io.ports", "io.uart.read", "io.uart.read-after-launch-flash", "io.uart.write", "io.uart.jsonrpc" ]); 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, selected_device_pod_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 device_pods ( id TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), profile_json TEXT NOT NULL DEFAULT '{}', profile_hash TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL, updated_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS device_pod_grants ( device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, created_by_admin_id TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, PRIMARY KEY (device_pod_id, user_id) )`, `CREATE TABLE IF NOT EXISTS device_pod_jobs ( id TEXT PRIMARY KEY, device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, owner_user_id TEXT NOT NULL REFERENCES users(id), status TEXT NOT NULL, intent TEXT NOT NULL, args_json TEXT NOT NULL DEFAULT '{}', reason TEXT NOT NULL DEFAULT '', trace_id TEXT NOT NULL, operation_id TEXT NOT NULL, output_json TEXT NOT NULL DEFAULT '{}', blocker_json TEXT NOT NULL DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT NOT NULL, completed_at TEXT )`, `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)` ]); const MUTATING_INTENTS = new Set([ "workspace.apply-patch", "workspace.build", "workspace.put", "workspace.rm", "workspace.rmdir", "workspace.keil", "debug.download", "debug.reset", "io.uart.read-after-launch-flash", "io.uart.write", "io.uart.jsonrpc" ]); const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set([ "status", "output", "wait", "cancel", "evidence" ]); const DEVICE_JOB_ACTIONABLE_INTENTS = new Set([ "workspace.build", "debug.download" ]); const DEVICE_JOB_OUTPUT_MAX_BYTES = 64000; const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu; const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u; 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, gatewayRegistry = null, fetchImpl = fetch, traceStore = null, codeAgentChatResults = null, devicePodExecutorUrl = env.HWLAB_DEVICE_POD_URL, devicePodExecutorTimeoutMs = 1200, devicePodSyncTimeoutMs = env.HWLAB_DEVICE_POD_SYNC_TIMEOUT_MS ?? 120000, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED), openFgaAuthorizer = null } = {}) { this.store = store; this.env = env; this.gatewayRegistry = gatewayRegistry; this.fetchImpl = fetchImpl; this.traceStore = traceStore; this.codeAgentChatResults = codeAgentChatResults; this.codeAgentEnv = env; this.devicePodExecutorUrl = normalizeBaseUrl(devicePodExecutorUrl); this.devicePodInternalToken = textOr(env.HWLAB_DEVICE_POD_INTERNAL_TOKEN, ""); this.devicePodApiKey = textOr(env.HWLAB_DEVICE_POD_API_KEY, ""); this.devicePodExecutorTimeoutMs = Number.parseInt(String(devicePodExecutorTimeoutMs), 10) || 1200; this.devicePodSyncTimeoutMs = Number.parseInt(String(devicePodSyncTimeoutMs), 10) || 120000; this.now = now; this.required = required; this.openFga = openFgaAuthorizer ?? createOpenFgaAuthorizer({ env, fetchImpl, now, configStore: store }); this.bootstrapAttempted = false; } configureCodeAgentWorkspaceContext({ env = null, fetchImpl = null, traceStore = null, codeAgentChatResults = null } = {}) { if (env) this.codeAgentEnv = env; if (fetchImpl) this.fetchImpl = fetchImpl; if (traceStore) this.traceStore = traceStore; if (codeAgentChatResults) this.codeAgentChatResults = codeAgentChatResults; } async handleAuthRoute(request, response, url) { try { await this.ensureBootstrap(); if (url.pathname === "/auth/oidc/login" && (request.method === "GET" || request.method === "POST")) { await this.handleOidcLogin(request, response, url); return; } if (url.pathname === "/auth/oidc/callback" && request.method === "GET") { await this.handleOidcCallback(request, response, url); return; } if (request.method === "GET" && url.pathname === "/auth/session") { sendJson(response, 200, await this.sessionPayload(request)); return; } if (request.method === "POST" && url.pathname === "/auth/login") { await this.handleLogin(request, response); return; } if (request.method === "POST" && url.pathname === "/auth/logout") { await this.handleLogout(request, response); return; } sendJson(response, 404, errorPayload("not_found", "Auth route is not implemented", 404)); } catch (error) { sendAccessError(response, error); } } async handleUserRoute(request, response, url) { try { await this.ensureBootstrap(); if (request.method === "GET" && url.pathname === "/v1/users/me") { const auth = await this.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); return sendJson(response, 200, { authenticated: true, actor: publicActor(auth.actor), session: auth.session, authMethod: publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)), contractVersion: "user-access-v1" }); } if (url.pathname === "/v1/api-keys" || url.pathname === "/v1/api-keys/default" || url.pathname.startsWith("/v1/api-keys/")) { return this.handleApiKeyRoute(request, response, url); } sendJson(response, 404, errorPayload("not_found", "User route is not implemented", 404)); } catch (error) { sendAccessError(response, error); } } async handleApiKeyRoute(request, response, url) { try { await this.ensureBootstrap(); const auth = await this.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); const path = url.pathname; if (request.method === "GET" && path === "/v1/api-keys") { const keys = await this.store.listApiKeysForUser?.(auth.actor.id) ?? []; return sendJson(response, 200, { contractVersion: "user-api-key-v1", keys: keys.map(publicApiKey), count: keys.length }); } if (request.method === "GET" && path === "/v1/api-keys/default") { const existing = await this.store.findActiveDefaultApiKeyForUser?.(auth.actor.id) ?? null; if (existing) return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(existing), created: false }); const issued = await this.store.ensureDefaultApiKeyForUser?.({ userId: auth.actor.id, now: this.now() }) ?? null; if (!issued?.key) return sendJson(response, 500, errorPayload("api_key_issue_failed", "Failed to issue default API key", 500)); return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(issued.key, { includeSecret: true }), created: true }); } if (request.method === "POST" && path === "/v1/api-keys") { const body = await jsonBody(request); const name = textOr(body.name, "").trim() || "User API key"; const secret = generateApiKey(); const prefix = apiKeyPrefixOf(secret); const hash = hashApiKey(secret); const created = await this.store.createApiKey?.({ userId: auth.actor.id, name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: this.now() }); if (!created) return sendJson(response, 500, errorPayload("api_key_create_failed", "Failed to create API key", 500)); return sendJson(response, 201, { contractVersion: "user-api-key-v1", key: publicApiKey(created, { includeSecret: true }), created: true }); } const regenerateMatch = path.match(/^\/v1\/api-keys\/([^/]+)\/regenerate$/u); if (request.method === "POST" && regenerateMatch) { const keyId = decodeURIComponent(regenerateMatch[1]); const existing = await this.store.findApiKeyById?.(keyId) ?? null; if (!existing || existing.userId !== auth.actor.id) return sendJson(response, 404, errorPayload("api_key_not_found", "API key was not found", 404)); await this.store.revokeApiKey?.(keyId, this.now()); const secret = generateApiKey(); const prefix = apiKeyPrefixOf(secret); const hash = hashApiKey(secret); const fresh = await this.store.createApiKey?.({ userId: auth.actor.id, name: existing.name, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: existing.scopes, now: this.now() }); return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(fresh, { includeSecret: true }), regenerated: true, oldKeyId: keyId }); } const deleteMatch = path.match(/^\/v1\/api-keys\/([^/]+)$/u); if (request.method === "DELETE" && deleteMatch) { const keyId = decodeURIComponent(deleteMatch[1]); const existing = await this.store.findApiKeyById?.(keyId) ?? null; if (!existing || existing.userId !== auth.actor.id) return sendJson(response, 404, errorPayload("api_key_not_found", "API key was not found", 404)); const revoked = await this.store.revokeApiKey?.(keyId, this.now()); return sendJson(response, 200, { contractVersion: "user-api-key-v1", key: publicApiKey(revoked), revoked: true }); } sendJson(response, 404, errorPayload("not_found", "API key route is not implemented", 404)); } catch (error) { sendAccessError(response, error); } } async handleAccessStatusRoute(request, response, url) { try { await this.ensureBootstrap(); if (request.method === "GET" && url.pathname === "/v1/access/status") { return sendJson(response, 200, await this.accessStatusPayload(request)); } if (request.method === "GET" && url.pathname === "/v1/setup/status") { return sendJson(response, 200, await this.setupStatusPayload()); } sendJson(response, 404, errorPayload("not_found", "Access status route is not implemented", 404)); } catch (error) { sendAccessError(response, error); } } async handleSetupRoute(request, response, url) { try { await this.ensureBootstrap(); if (request.method !== "POST" || url.pathname !== "/v1/setup/first-admin") { return sendJson(response, 404, errorPayload("not_found", "Setup route is not implemented", 404)); } if (!(await this.setupRequired())) { return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409)); } const body = await jsonBody(request); const password = requiredText(body.password, "password"); const devicePodSeeds = firstAdminDevicePodSeeds(body); 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 devicePodsInitialized = []; for (const seed of devicePodSeeds) { const pod = await this.store.upsertDevicePod({ ...seed, now }); const grant = await this.store.grantDevicePod({ devicePodId: pod.id, userId: user.id, createdByAdminId: user.id, now }); devicePodsInitialized.push({ devicePod: publicDevicePod(pod, { includeAdmin: true }), grant }); } 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, devicePodBootstrap: { requested: devicePodSeeds.length, initialized: devicePodsInitialized.length }, devicePodsInitialized, 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.devicePodApiKey) { return sendJson(response, 403, errorPayload("admin_session_required", "Admin APIs require a user session", 403)); } 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) }); } if ((request.method === "POST" && url.pathname === "/v1/admin/device-pods") || (request.method === "PUT" && url.pathname.startsWith("/v1/admin/device-pods/"))) { const body = await jsonBody(request); const devicePodId = request.method === "PUT" ? decodeURIComponent(url.pathname.split("/").filter(Boolean)[3] ?? "") : textOr(body.devicePodId ?? body.id, ""); const profile = assertDevicePodProfileSafe(normalizeObject(body.profile ?? body.profileJson), "profile"); const pod = await this.store.upsertDevicePod({ id: requiredText(devicePodId, "devicePodId"), name: textOr(body.name, devicePodId), status: body.status === "disabled" ? "disabled" : "active", profile, now: this.now() }); return sendJson(response, request.method === "POST" ? 201 : 200, { upserted: true, devicePod: publicDevicePod(pod, { includeAdmin: true }) }); } if (request.method === "POST" && url.pathname === "/v1/admin/device-pod-grants") { const body = await jsonBody(request); const devicePodId = requiredText(body.devicePodId, "devicePodId"); const userId = requiredText(body.userId, "userId"); await this.requireGrantTargets({ devicePodId, userId }); const grant = await this.store.grantDevicePod({ devicePodId, userId, createdByAdminId: auth.actor.id, now: this.now() }); await this.grantDevicePodRelation({ userId, devicePodId, relation: "viewer", admin: auth.actor, mirrorLegacy: false }); await this.grantDevicePodRelation({ userId, devicePodId, relation: "operator", admin: auth.actor, mirrorLegacy: false }); await this.grantDevicePodRelation({ userId, devicePodId, relation: "job_submitter", admin: auth.actor, mirrorLegacy: false }); return sendJson(response, 201, { granted: true, grant }); } if (request.method === "DELETE" && url.pathname.startsWith("/v1/admin/device-pod-grants/")) { const [, , , devicePodId, userId] = url.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part)); await this.store.revokeDevicePodGrant({ devicePodId: requiredText(devicePodId, "devicePodId"), userId: requiredText(userId, "userId") }); await this.revokeDevicePodRelation({ userId, devicePodId, relation: "viewer" }); await this.revokeDevicePodRelation({ userId, devicePodId, relation: "operator" }); await this.revokeDevicePodRelation({ userId, devicePodId, relation: "job_submitter" }); return sendJson(response, 200, { revoked: true, devicePodId, userId }); } 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 devicePodRelationMatch = url.pathname.match(/^\/v1\/admin\/access\/users\/([^/]+)\/device-pods\/([^/]+)\/([^/]+)$/u); if (devicePodRelationMatch && (request.method === "PUT" || request.method === "DELETE")) { const userId = decodeURIComponent(devicePodRelationMatch[1]); const devicePodId = decodeURIComponent(devicePodRelationMatch[2]); const relation = decodeURIComponent(devicePodRelationMatch[3]); if (!DEVICE_POD_RELATIONS.includes(relation)) return sendJson(response, 400, errorPayload("invalid_device_pod_relation", "Device pod relation is not supported", 400)); await this.requireGrantTargets({ devicePodId, userId }); const result = request.method === "PUT" ? await this.grantDevicePodRelation({ userId, devicePodId, relation, admin: auth.actor }) : await this.revokeDevicePodRelation({ userId, devicePodId, relation }); const user = await this.store.getUserById(userId); return sendJson(response, request.method === "PUT" ? 201 : 200, { ok: result.ok !== false, changed: true, result, access: user ? await this.userAccessMatrixPayload(user, auth.actor) : null }); } 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 }); 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 legacyAllowed = await this.legacyAllowedForRelation({ user, relation, object }); const authorization = await this.openFga.check({ actor: user, relation, object, legacyAllowed }); 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, pods, tuples, openfga] = await Promise.all([ this.store.listUsers?.() ?? [], this.store.listDevicePods?.() ?? [], this.store.listAccessTuples?.() ?? [], this.openFga.describe() ]); const toolTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "tool").length; const devicePodTupleCount = tuples.filter((tuple) => parseOpenFgaObject(tuple.object)?.type === "device_pod").length; return { contractVersion: "admin-access-v1", actor: publicActor(actor), openfga, counts: { users: users.length, devicePods: pods.length, tuples: tuples.length, toolTuples: toolTupleCount, devicePodTuples: devicePodTupleCount }, supported: { devicePodRelations: DEVICE_POD_RELATIONS, toolIds: HWLAB_TOOL_IDS } }; } async userAccessSummary(user) { const tuples = await this.store.listAccessTuples?.({ userId: user.id }) ?? []; return { user: redactedUser(user), tupleCount: tuples.length, tools: toolsFromTuples(tuples), devicePodCount: devicePodMatrixFromTuples(tuples).length }; } async userAccessMatrixPayload(user, actor) { const [tuples, pods, openfga] = await Promise.all([ this.store.listAccessTuples?.({ userId: user.id }) ?? [], this.store.listDevicePods?.() ?? [], this.openFga.describe() ]); const devicePods = pods.map((pod) => { const relations = Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, tuples.some((tuple) => tuple.object === openFgaObject("device_pod", pod.id) && tuple.relation === relation)])); return { devicePod: publicDevicePod(pod, { includeAdmin: true }), relations }; }); const tools = Object.fromEntries(HWLAB_TOOL_IDS.map((toolId) => [toolId, tuples.some((tuple) => tuple.object === openFgaObject("tool", toolId) && tuple.relation === "can_use")])); return { contractVersion: "admin-access-v1", actor: publicActor(actor), user: redactedUser(user), openfga, devicePods, tools, tuples: tuples.map(publicAccessTuple) }; } 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 grantDevicePodRelation({ userId, devicePodId, relation, admin, mirrorLegacy = true }) { const object = openFgaObject("device_pod", devicePodId); const result = await this.grantAccessTuple({ userId, relation, object, admin }); if (mirrorLegacy && ["viewer", "operator", "job_submitter"].includes(relation)) { await this.store.grantDevicePod?.({ devicePodId, userId, createdByAdminId: admin.id, now: this.now() }); } return result; } async revokeDevicePodRelation({ userId, devicePodId, relation }) { const object = openFgaObject("device_pod", devicePodId); const result = await this.revokeAccessTuple({ userId, relation, object }); if (["viewer", "operator", "job_submitter"].includes(relation)) { const tuples = await this.store.listAccessTuples?.({ userId, object }) ?? []; const stillHasLegacyEquivalent = tuples.some((tuple) => ["viewer", "operator", "job_submitter"].includes(tuple.relation)); if (!stillHasLegacyEquivalent) await this.store.revokeDevicePodGrant?.({ devicePodId, userId }); } return result; } async grantAccessTuple({ userId, relation, object, admin }) { await this.store.upsertAccessTuple?.({ userId, relation, object, createdByAdminId: admin?.id ?? userId, now: this.now() }); return this.openFga.writeTuple({ userId, relation, object }); } async revokeAccessTuple({ userId, relation, object }) { await this.store.deleteAccessTuple?.({ userId, relation, object }); return this.openFga.deleteTuple({ userId, relation, object }); } async legacyAllowedForRelation({ user, relation, object }) { if (user.role === "admin") return true; const parsed = parseOpenFgaObject(object); if (parsed?.type === "device_pod") return Boolean(await this.store.hasDevicePodGrant?.({ devicePodId: parsed.id, userId: user.id })); if (parsed?.type === "tool") return false; if (parsed?.type === "system") return user.role === "admin"; return false; } async listVisibleDevicePods(actor) { const pods = await this.store.listDevicePods?.() ?? []; const visible = []; for (const pod of pods.filter((item) => item.status === "active")) { const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); const authorization = await this.openFga.check({ actor, relation: "viewer", object: openFgaObject("device_pod", pod.id), legacyAllowed }); if (authorization.allowed) visible.push(pod); } return visible; } async getVisibleDevicePod(actor, id) { return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; } async authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed = false }) { const authorization = await this.openFga.check({ actor, relation, object: openFgaObject("device_pod", pod.id), legacyAllowed: actor.role === "admin" || legacyAllowed }); return authorization; } async handleDevicePodRoute(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 (request.method === "GET" && url.pathname === "/v1/device-pods") { const pods = await this.listVisibleDevicePods(auth.actor); return sendJson(response, 200, { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "device-pod-authority-v1", status: "ok", source: authoritySource(), actor: publicActor(auth.actor), devicePods: pods.map((pod) => publicDevicePod(pod)), selectedDevicePodId: pods[0]?.id ?? null, observedAt: this.now() }); } const parsed = parseDevicePodPath(url.pathname); if (!parsed) return sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); const pod = await this.getVisibleDevicePod(auth.actor, parsed.devicePodId); if (!pod) { const existing = await this.store.getDevicePod(parsed.devicePodId); if (existing?.status === "active") { return sendJson(response, 403, errorPayload("device_pod_forbidden", `Device Pod ${parsed.devicePodId} is not authorized for the current actor`, 403)); } return sendJson(response, 404, errorPayload("device_pod_not_found", `Device Pod ${parsed.devicePodId} was not found`, 404)); } if (request.method === "GET" && parsed.route === "status") return sendJson(response, 200, this.devicePodStatus(pod, auth.actor)); if (request.method === "GET" && parsed.route === "events") return sendJson(response, 200, await this.devicePodEvents(pod, url.searchParams)); if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "debug-probe", intent: "debug.chip-id" }); if (request.method === "GET" && parsed.route === "io-probe/uart/1") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.ports", args: { uartId: "uart/1" } }); if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return this.createDevicePodProbeJob(response, pod, auth.actor, { interfaceName: "io-probe", intent: "io.uart.read", args: uartTailArgs(url.searchParams, "uart/1"), outputMaxBytes: boundedOutputMaxBytes(url.searchParams) }); if (request.method === "POST" && parsed.route === "jobs") return this.createDevicePodJob(request, response, pod, auth.actor); if (request.method === "GET" && parsed.route.startsWith("jobs/")) return this.getDevicePodJob(response, pod, parsed.route, auth.actor); if (request.method === "POST" && parsed.route.startsWith("jobs/") && parsed.route.endsWith("/cancel")) return this.cancelDevicePodJob(response, pod, parsed.route, auth.actor); sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); } catch (error) { sendAccessError(response, error); } } async authenticate(request, { required = this.required } = {}) { await this.ensureBootstrap(); const devicePodApiKey = devicePodApiKeyFromRequest(request); if (devicePodApiKey) return this.authenticateDevicePodApiKey(devicePodApiKey); 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 authenticateDevicePodApiKey(apiKey) { if (!this.devicePodApiKey || !constantSecretEquals(apiKey, this.devicePodApiKey)) { return errorPayload("device_pod_api_key_invalid", "Device Pod API key is missing or invalid", 401); } const actor = await this.bootstrapAdminActor(); if (!actor || actor.status !== "active") return errorPayload("device_pod_api_key_actor_unavailable", "Device Pod API key actor is unavailable", 401); return { ok: true, actor, session: { id: "device-pod-api-key", tokenSource: "device-pod-api-key", valuesRedacted: true }, devicePodApiKey: 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 createCodeAgentDevicePodApiKey() { await this.ensureBootstrap(); if (!this.devicePodApiKey) return null; const actor = await this.bootstrapAdminActor(); if (!actor || actor.status !== "active") return null; return { apiKey: this.devicePodApiKey, actor: publicActor(actor), tokenSource: "code-agent-device-pod-api-key", valuesRedacted: true }; } async codeAgentToolCapabilitiesForOwner(ownerUserId) { await this.ensureBootstrap(); if (this.openFga.mode === "off") return null; const actor = await this.store.getUserById(ownerUserId); if (!actor || actor.status !== "active") return defaultDeniedToolCapabilities(); const entries = {}; for (const toolId of HWLAB_TOOL_IDS) { const legacyAllowed = false; const authorization = await this.openFga.check({ actor, relation: "can_use", object: openFgaObject("tool", toolId), legacyAllowed }); 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(); 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(); } 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_device_pod_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_device_pod_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_device_pod_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 selectedDevicePodId = textOr(body.selectedDevicePodId ?? body.devicePodId, current.selectedDevicePodId ?? ""); if (selectedDevicePodId && !(await this.store.getVisibleDevicePod(auth.actor, selectedDevicePodId))) { return sendJson(response, 403, errorPayload("workspace_device_pod_forbidden", "Selected device pod 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, selectedDevicePodId, 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, selectedDevicePodId: 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, selectedDevicePodId: workspace.selectedDevicePodId, 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; const selectedDevicePod = workspace?.selectedDevicePodId ? await this.store.getVisibleDevicePod(actor, workspace.selectedDevicePodId) : null; return publicWorkbenchWorkspace(workspace, { conversation, selectedDevicePod }); } async syncTerminalWorkbenchConversation({ workspace, actor, result, activeTraceId, selectedConversationId, selectedAgentSessionId, now }) { if (!safeConversationIdLocal(selectedConversationId) || !safeAgentSessionId(selectedAgentSessionId)) return null; const existing = await this.visibleConversationForActor(actor, selectedConversationId, workspace.projectId, { skipTerminalRepair: true }); const sessionStatus = terminalWorkbenchSessionStatus(result); const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, existing?.threadId ?? null); const workspaceJson = normalizeObject(workspace?.workspace); const workspaceMessages = workspace?.selectedConversationId === selectedConversationId && workspace?.selectedAgentSessionId === selectedAgentSessionId && Array.isArray(workspaceJson.messages) ? workspaceJson.messages : []; const existingMessages = Array.isArray(existing?.messages) && existing.messages.length > 0 ? existing.messages : workspaceMessages; const messages = mergeTerminalConversationMessages(existingMessages, result, { traceId: activeTraceId, conversationId: selectedConversationId, sessionId: selectedAgentSessionId, threadId, now, existingMessageCount: existing?.messageCount ?? existing?.snapshot?.messageCount ?? (workspaceMessages.length || null), firstUserMessagePreview: existing?.firstUserMessagePreview ?? existing?.snapshot?.firstUserMessagePreview ?? firstUserPreviewFromMessages(workspaceMessages) ?? null }); const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? []; const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null; const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null; return await this.recordAgentSessionOwner({ ownerUserId: actor.id, sessionId: selectedAgentSessionId, projectId: workspace.projectId, agentId: existing?.agentId ?? "hwlab-code-agent", status: sessionStatus, conversationId: selectedConversationId, threadId, traceId: activeTraceId, session: normalizeConversationSnapshot({ ...(existing?.snapshot ?? {}), source: "workbench-status-sync", sessionStatus, updatedAt: now, lastTraceId: activeTraceId, messages: mergedMessages, messageCount: mergedCount, firstUserMessagePreview: mergedPreview }, actor), now }); } async terminalCodeAgentResultForActor(traceId, actor) { const safeTraceId = safeTraceIdLocal(traceId); const codeAgentEnv = this.codeAgentEnv ?? this.env; if (!safeTraceId || !codeAgentAgentRunAdapterEnabled(codeAgentEnv)) return null; const options = { env: codeAgentEnv, fetchImpl: this.fetchImpl, accessController: this, codeAgentChatResults: this.codeAgentChatResults, traceStore: this.traceStore ?? defaultCodeAgentTraceStore }; try { const cached = this.codeAgentChatResults?.get?.(safeTraceId) ?? null; const persisted = cached ? null : await loadPersistedAgentRunResult(safeTraceId, options); const current = cached ?? persisted ?? null; if (!current || !canActorReadWorkspaceTrace(current, actor)) return null; const synced = current?.agentRun?.runId && current.status === "running" ? await syncAgentRunChatResult({ traceId: safeTraceId, currentResult: current, options, traceStore: options.traceStore }) : { result: current }; const result = synced.result ?? current; return isTerminalCodeAgentResult(result) ? result : null; } catch { return null; } } async repairVisibleConversationsIfNeeded(actor, conversations = [], projectId = "") { if (!Array.isArray(conversations) || conversations.length === 0) return []; const repaired = []; for (const conversation of conversations) { repaired.push(await this.repairVisibleConversationIfNeeded(actor, conversation, projectId) ?? conversation); } return repaired; } async repairVisibleConversationIfNeeded(actor, conversation, projectId = "") { if (textOr(conversation?.status, "").toLowerCase() === "archived") return conversation; const traceId = terminalConversationFallbackRepairTraceId(conversation); if (!traceId) return conversation; const result = await this.terminalCodeAgentResultForActor(traceId, actor); if (!result) return conversation; const sessionId = safeAgentSessionId( result.sessionId ?? result.session?.sessionId ?? result.sessionReuse?.sessionId ?? conversation.sessionId ?? conversation.session?.sessionId ); const conversationId = safeConversationIdLocal(result.conversationId) ? result.conversationId : conversation.conversationId; if (!safeConversationIdLocal(conversationId) || !sessionId) return conversation; const now = this.now(); const threadId = textOr(result.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.providerTrace?.threadId, conversation.threadId ?? null); const messages = mergeTerminalConversationMessages(conversation.messages, result, { traceId, conversationId, sessionId, threadId, now, existingMessageCount: conversation.messageCount ?? conversation.snapshot?.messageCount ?? null, firstUserMessagePreview: conversation.firstUserMessagePreview ?? conversation.snapshot?.firstUserMessagePreview ?? null }); const mergedMessages = Array.isArray(messages) ? messages : messages?.messages ?? []; const mergedCount = Array.isArray(messages) ? null : messages?.messageCount ?? null; const mergedPreview = Array.isArray(messages) ? null : messages?.firstUserMessagePreview ?? null; const sessionStatus = terminalWorkbenchSessionStatus(result); const repaired = await this.recordAgentSessionOwner({ ownerUserId: actor.id, sessionId, projectId: textOr(conversation.projectId ?? projectId, "prj_device_pod_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; } async requireGrantTargets({ devicePodId, userId }) { const pod = await this.store.getDevicePod(devicePodId); if (!pod || pod.status !== "active") { throw Object.assign(new Error(`Device Pod ${devicePodId} does not exist or is disabled`), { statusCode: 404, code: "device_pod_not_found" }); } const user = await this.store.getUserById(userId); if (!user || user.status !== "active") { throw Object.assign(new Error(`User ${userId} does not exist or is disabled`), { statusCode: 404, code: "user_not_found" }); } } devicePodStatus(pod, actor) { const route = pod.profile.route ?? {}; const gatewaySessionId = textOr(route.gatewaySessionId, ""); const gatewayOnline = gatewaySessionId && this.gatewayRegistry?.isOnline?.(gatewaySessionId) === true; const blocker = gatewayOnline ? null : gatewayDispatchBlocker(gatewaySessionId ? "gateway session is not connected" : "profile route.gatewaySessionId is missing"); const observedAt = this.now(); const status = blocker ? "blocked" : "ok"; const output = boundedOutput({ text: blocker?.summary ?? "device-pod status ok", summary: blocker?.summary ?? "device-pod status ok" }); const freshnessPayload = freshness(observedAt, blocker); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "device-pod-authority-v1", status, devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, traceId: `trc_devicepod_${randomUUID()}`, operationId: `op_devicepod_${randomUUID()}`, observedAt, source: authoritySource(), actor: publicActor(actor), devicePod: publicDevicePod(pod), blocker, freshness: freshnessPayload, output: output.output, text: output.text, bytes: output.bytes, truncation: output.truncation, summary: { devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), status, freshness: freshnessPayload, profileHash: pod.profileHash, blocker } }; } async devicePodEvents(pod, searchParams) { const limit = Math.min(Math.max(Number.parseInt(searchParams.get("limit") ?? "80", 10) || 80, 1), 1000); const jobs = await this.store.listDevicePodJobs(pod.id, limit); const events = jobs.map((job) => ({ eventId: `evt_${job.id}`, devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), ts: job.updatedAt, level: job.status === "blocked" || job.status === "failed" ? "warn" : "info", scope: "job", intent: job.intent, status: job.status, summary: normalizeBlocker(job.blocker)?.summary ?? `job ${job.id} ${job.status}`, blocker: normalizeBlocker(job.blocker), refs: jobRefs(job) })); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: "device-pod-authority-v1", status: "ok", observedAt: this.now(), devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), source: authoritySource(), events, lines: events.map(formatEventLine), truncation: { limit, returned: events.length, truncated: jobs.length >= limit } }; } async createDevicePodProbeJob(response, pod, actor, { interfaceName, intent, args = {}, outputMaxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES }) { const result = await this.createReadOnlyDevicePodJob({ pod, actor, intent, args }); const payload = this.jobOutputPayload(result.job, pod, { maxBytes: outputMaxBytes }); return sendJson(response, result.httpStatus, { ...payload, interface: interfaceName, intent, source: authoritySource() }); } async createReadOnlyDevicePodJob({ pod, actor, intent, args = {} }) { if (!DEVICE_JOB_INTENTS.has(intent) || MUTATING_INTENTS.has(intent)) { throw Object.assign(new Error(`Device probe intent ${intent} is not supported as a read-only probe`), { statusCode: 400, code: "unsupported_device_probe_intent" }); } const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation: "viewer", legacyAllowed }); if (!authorization.allowed) { return { job: await this.createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args, reason: "", authorization }), httpStatus: 403 }; } const traceId = `trc_devicepod_${randomUUID()}`; const operationId = `op_devicepod_${randomUUID()}`; const now = this.now(); const job = await this.store.createDevicePodJob({ id: `job_devicepod_${randomUUID()}`, devicePodId: pod.id, ownerUserId: actor.id, status: "running", intent, args: normalizeObject(args), reason: "", traceId, operationId, output: { text: "" }, blocker: null, now, completedAt: null }); if (this.devicePodExecutorUrl) return this.dispatchDevicePodExecutorJob({ job, pod, actor }); return this.blockDevicePodJobWithoutExecutor({ job, pod }); } async createDevicePodJob(request, response, pod, actor) { const body = await jsonBody(request); const intent = requiredText(body.intent, "intent"); if (!DEVICE_JOB_INTENTS.has(intent)) { return sendJson(response, 400, errorPayload("unsupported_device_job_intent", `Device job intent ${intent} is not supported`, 400)); } const reason = textOr(body.reason, ""); if (_deviceJobRequiresReason(intent, normalizeObject(body.args), reason)) { return this.rejectDevicePodJob(response, pod, actor, { httpStatus: 400, intent, args: normalizeObject(body.args), blocker: deviceJobBlocked("device_job_reason_required", `Device job intent ${intent} requires reason`, false) }); } const args = normalizeObject(body.args); const legacyAllowed = actor.role === "admin" || await this.store.hasDevicePodGrant?.({ devicePodId: pod.id, userId: actor.id }); const relation = MUTATING_INTENTS.has(intent) ? "job_submitter" : "operator"; const authorization = await this.authorizeDevicePodOperation({ actor, pod, relation, legacyAllowed }); if (!authorization.allowed) { return this.rejectDevicePodJob(response, pod, actor, { httpStatus: 403, intent, args, reason, blocker: authorizationBlocker("device_pod_authorization_denied", `Device job intent ${intent} is not authorized for relation ${relation}`, authorization) }); } const traceId = `trc_devicepod_${randomUUID()}`; const operationId = `op_devicepod_${randomUUID()}`; const now = this.now(); const job = await this.store.createDevicePodJob({ id: `job_devicepod_${randomUUID()}`, devicePodId: pod.id, ownerUserId: actor.id, status: "running", intent, args, reason, traceId, operationId, output: {}, blocker: null, now, completedAt: null }); const dispatched = this.devicePodExecutorUrl ? await this.dispatchDevicePodExecutorJob({ job, pod, actor }) : await this.blockDevicePodJobWithoutExecutor({ job, pod }); const payload = shouldReturnDevicePodCreateOutput(dispatched.job) ? this.jobOutputPayload(dispatched.job, pod) : this.jobPayload(dispatched.job, pod); sendJson(response, dispatched.httpStatus, payload); } async rejectDevicePodJob(response, pod, actor, { httpStatus, intent, reason = "", args = {}, blocker }) { const now = this.now(); const job = await this.store.createDevicePodJob({ id: `job_devicepod_${randomUUID()}`, devicePodId: pod.id, ownerUserId: actor.id, status: "blocked", intent, args, reason, traceId: `trc_devicepod_${randomUUID()}`, operationId: `op_devicepod_${randomUUID()}`, output: { error: blocker.summary }, blocker, now, completedAt: now }); return sendJson(response, httpStatus, { ...this.jobPayload(job, pod), error: { code: blocker.code, message: blocker.summary } }); } async createBlockedAuthorizationDevicePodJob({ pod, actor, intent, args = {}, reason = "", authorization }) { const now = this.now(); const blocker = authorizationBlocker("device_pod_authorization_denied", `Device Pod ${pod.id} is not authorized for the current actor`, authorization); return this.store.createDevicePodJob({ id: `job_devicepod_${randomUUID()}`, devicePodId: pod.id, ownerUserId: actor.id, status: "blocked", intent, args, reason, traceId: `trc_devicepod_${randomUUID()}`, operationId: `op_devicepod_${randomUUID()}`, output: { error: blocker.summary }, blocker, now, completedAt: now }); } async dispatchDevicePodExecutorJob({ job, pod, actor }) { const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs`; try { const response = await fetchJsonWithTimeout(this.fetchImpl, target, { method: "POST", headers: { accept: "application/json", "content-type": "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) }, body: stableJson({ jobId: job.id, devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: pod.profile, ownerUserId: actor.id, intent: job.intent, args: job.args, reason: job.reason, traceId: job.traceId, operationId: job.operationId }) }, devicePodExecutorCreateTimeoutMs(this, job)); const updated = await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); const status = updated.status; return { job: updated ?? job, httpStatus: response.status >= 400 ? response.status : status === "running" ? 202 : 200 }; } catch (error) { const blocker = devicePodExecutorBlocker(error?.message ?? "device-pod executor request failed"); const updated = await this.store.updateDevicePodJob(pod.id, job.id, { status: "blocked", output: { error: blocker.summary }, blocker, completedAt: this.now(), updatedAt: this.now() }); return { job: updated ?? job, httpStatus: 409 }; } } async getDevicePodJob(response, pod, route, actor) { const parts = route.split("/"); const jobId = decodeURIComponent(parts[1] ?? ""); let job = await this.store.getDevicePodJob(pod.id, jobId); if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can inspect the job", 403)); if (this.devicePodExecutorUrl && !terminalJobStatus(job.status)) { job = await this.refreshDevicePodExecutorJob({ job, pod, output: parts[2] === "output" }); } if (parts[2] === "output") return sendJson(response, 200, this.jobOutputPayload(job, pod)); return sendJson(response, 200, this.jobPayload(job, pod)); } async cancelDevicePodJob(response, pod, route, actor) { const jobId = decodeURIComponent(route.split("/")[1] ?? ""); const job = await this.store.getDevicePodJob(pod.id, jobId); if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can cancel the job", 403)); if (this.devicePodExecutorUrl && !terminalJobStatus(job.status)) { const canceledByExecutor = await this.cancelDevicePodExecutorJob({ job, pod }); return sendJson(response, 200, this.jobPayload(canceledByExecutor, pod)); } const canceled = await this.store.updateDevicePodJob(pod.id, jobId, { status: terminalJobStatus(job.status) ? job.status : "canceled", blocker: terminalJobStatus(job.status) ? job.blocker : { code: "device_job_canceled", summary: "Device job was canceled by user" }, completedAt: terminalJobStatus(job.status) ? job.completedAt : this.now(), updatedAt: this.now() }); return sendJson(response, 200, this.jobPayload(canceled, pod)); } async refreshDevicePodExecutorJob({ job, pod, output = false }) { const suffix = output ? "/output" : ""; const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}${suffix}`; try { const response = await fetchJsonWithTimeout(this.fetchImpl, target, { method: "GET", headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } }, this.devicePodExecutorTimeoutMs); return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); } catch (error) { return await this.blockDevicePodJobOnExecutorError({ job, pod, error }); } } async cancelDevicePodExecutorJob({ job, pod }) { const target = `${this.devicePodExecutorUrl}/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel`; try { const response = await fetchJsonWithTimeout(this.fetchImpl, target, { method: "POST", headers: { accept: "application/json", ...devicePodInternalHeaders(CLOUD_API_SERVICE_ID, this.devicePodInternalToken) } }, this.devicePodExecutorTimeoutMs); return await this.updateDevicePodJobFromExecutorResponse({ job, pod, response }); } catch (error) { return await this.blockDevicePodJobOnExecutorError({ job, pod, error }); } } async updateDevicePodJobFromExecutorResponse({ job, pod, response }) { const status = normalizeDeviceJobStatus(response.body?.status ?? response.body?.job?.status, response.status); const blocker = response.body?.blocker ?? (status === "completed" || status === "running" || status === "queued" ? null : devicePodExecutorBlocker(`device-pod executor returned HTTP ${response.status}`)); const updated = await this.store.updateDevicePodJob(pod.id, job.id, { status, output: executorOutputPayload(response.body, response.status), blocker, completedAt: terminalJobStatus(status) ? job.completedAt ?? this.now() : null, updatedAt: this.now() }); return updated ?? job; } async blockDevicePodJobOnExecutorError({ job, pod, error }) { const blocker = devicePodExecutorBlocker(error?.message ?? "device-pod executor request failed"); const updated = await this.store.updateDevicePodJob(pod.id, job.id, { status: "blocked", output: { error: blocker.summary }, blocker, completedAt: this.now(), updatedAt: this.now() }); return updated ?? job; } async blockDevicePodJobWithoutExecutor({ job, pod }) { const blocker = devicePodExecutorBlocker("HWLAB_DEVICE_POD_URL is not configured; cloud-api will not bypass hwlab-device-pod executor"); const updated = await this.store.updateDevicePodJob(pod.id, job.id, { status: "blocked", output: { error: blocker.summary }, blocker, completedAt: this.now(), updatedAt: this.now() }); return { job: updated ?? job, httpStatus: 409 }; } jobPayload(job, pod) { const blocker = normalizeBlocker(job.blocker); return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: DEVICE_JOB_CONTRACT_VERSION, accepted: !["blocked", "failed"].includes(job.status), status: job.status, devicePodId: pod.id, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, traceId: job.traceId, operationId: job.operationId, job: publicJob(job), blocker, freshness: freshness(job.updatedAt, blocker), outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, cancelUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel` }; } jobOutputPayload(job, pod, { maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES } = {}) { const bounded = boundedOutput(job.output, maxBytes); return { ...this.jobPayload(job, pod), output: bounded.output, text: bounded.text, bytes: bounded.bytes, truncation: bounded.truncation }; } } 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.devicePods = new Map(); this.grants = new Set(); this.jobs = 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 || existing.role !== "admin") return null; const user = { ...existing, passwordHash: input.passwordHash, updatedAt: input.now ?? this.now() }; this.users.set(user.id, user); return user; } async createUser(input) { const now = input.now ?? this.now(); const existing = await this.findUserByUsername(input.username); const user = { id: input.id ?? existing?.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? existing?.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? existing?.passwordHash ?? null, authProvider: textOr(input.authProvider, existing?.authProvider ?? "local"), keycloakIssuer: textOr(input.keycloakIssuer, existing?.keycloakIssuer ?? "") || null, keycloakSub: textOr(input.keycloakSub, existing?.keycloakSub ?? "") || null, email: textOr(input.email, existing?.email ?? "") || null, lastLoginAt: textOr(input.lastLoginAt, existing?.lastLoginAt ?? "") || null, createdAt: existing?.createdAt ?? now, updatedAt: now }; this.users.set(user.id, user); return user; } async createFirstAdmin(input) { if (this.users.size > 0) return null; return this.createUser({ ...input, role: "admin", status: "active" }); } async createSession(input) { const session = { id: `uss_${randomUUID()}`, userId: input.userId, tokenHash: input.tokenHash, createdAt: input.now, lastSeenAt: input.now, expiresAt: input.expiresAt, revokedAt: null }; this.sessions.set(session.id, session); return session; } async findSessionByTokenHash(tokenHash, now) { const session = [...this.sessions.values()].find((item) => item.tokenHash === tokenHash && !item.revokedAt && item.expiresAt > now) ?? null; return session ? { ...session, user: this.users.get(session.userId) ?? null } : null; } async touchSession(id, now) { const session = this.sessions.get(id); if (session) session.lastSeenAt = now; } async revokeSessionByTokenHash(tokenHash, now) { for (const session of this.sessions.values()) if (session.tokenHash === tokenHash) session.revokedAt = now; } async createApiKey(input) { const now = input.now ?? this.now(); const key = { id: input.id ?? `key_${randomUUID()}`, userId: input.userId, name: input.name ?? API_KEY_DEFAULT_NAME, keyPrefix: input.keyPrefix, keyHash: input.keyHash ?? null, displaySecret: input.displaySecret ?? null, scopes: Array.isArray(input.scopes) ? input.scopes : [], status: input.status ?? "active", createdAt: now, lastUsedAt: null, revokedAt: null }; this.apiKeys.set(key.id, key); return key; } async findApiKeyById(id) { const key = this.apiKeys.get(textOr(id, "")); if (!key || key.status !== "active") return null; return key; } async findApiKeyByPrefix(prefix) { if (!prefix) return null; for (const key of this.apiKeys.values()) { if (key.status === "active" && key.keyPrefix === prefix) return key; } return null; } async findApiKeyByHash(hash) { if (!hash) return null; for (const key of this.apiKeys.values()) { if (key.status === "active" && key.keyHash === hash) return key; } return null; } async findApiKeyWithHashByPrefix(prefix) { if (!prefix) return null; for (const key of this.apiKeys.values()) { if (key.keyPrefix === prefix) return key; } return null; } async listApiKeysForUser(userId) { return [...this.apiKeys.values()].filter((key) => key.userId === userId).sort((a, b) => String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? ""))); } async findActiveDefaultApiKeyForUser(userId) { for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active" && key.name === API_KEY_DEFAULT_NAME) return key; } return null; } async touchApiKey(id, now) { const key = this.apiKeys.get(id); if (key) key.lastUsedAt = now; } async revokeApiKey(id, now) { const key = this.apiKeys.get(id); if (key) { key.status = "revoked"; key.revokedAt = now; } return key ?? null; } async revokeApiKeysForUser(userId, now) { let count = 0; for (const key of this.apiKeys.values()) { if (key.userId === userId && key.status === "active") { key.status = "revoked"; key.revokedAt = now; count += 1; } } return count; } async upsertOidcState(input) { this.oidcStates.set(input.state, { state: input.state, nonce: input.nonce, returnTo: input.returnTo, createdAt: input.createdAt, expiresAt: input.expiresAt }); return { ok: true }; } async consumeOidcState(state) { const entry = this.oidcStates.get(state) ?? null; if (entry) this.oidcStates.delete(state); return entry; } async findUserByKeycloakSubject(issuer, sub) { for (const user of this.users.values()) { if (user.authProvider === "keycloak" && user.keycloakIssuer === issuer && user.keycloakSub === sub) return user; } return null; } async createUserOidc(input) { const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const user = { id, username: input.username, displayName: input.displayName ?? input.username, role: input.role ?? "user", status: input.status ?? "active", passwordHash: null, authProvider: "keycloak", keycloakIssuer: input.keycloakIssuer, keycloakSub: input.keycloakSub, email: input.email, lastLoginAt: now, createdAt: now, updatedAt: now }; this.users.set(id, user); return user; } async updateUserOidcLogin(input) { const user = this.users.get(input.userId); if (!user) return null; this.users.set(input.userId, { ...user, email: input.email, lastLoginAt: input.lastLoginAt, updatedAt: this.now() }); return this.users.get(input.userId); } async ensureDefaultApiKeyForUser(input = {}) { const existing = await this.findActiveDefaultApiKeyForUser(input.userId); if (existing) return { key: existing, created: false }; const secret = input.secret ?? generateApiKey(); const prefix = apiKeyPrefixOf(secret); const hash = hashApiKey(secret); const key = await this.createApiKey({ userId: input.userId, name: API_KEY_DEFAULT_NAME, keyPrefix: prefix, keyHash: hash, displaySecret: secret, scopes: [], now: input.now }); return { key, secret, created: true }; } async upsertDevicePod(input) { const existing = this.devicePods.get(input.id); const profile = devicePodProfileForAuthority(input.id, normalizeObject(input.profile)); const now = input.now ?? this.now(); const pod = { id: input.id, name: input.name ?? existing?.name ?? input.id, status: input.status ?? existing?.status ?? "active", profile, profileHash: profileHash(profile), createdAt: existing?.createdAt ?? now, updatedAt: now }; this.devicePods.set(pod.id, pod); return pod; } async grantDevicePod(input) { const grant = { devicePodId: input.devicePodId, userId: input.userId, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; this.grants.add(grantKey(grant.devicePodId, grant.userId)); return grant; } async revokeDevicePodGrant(input) { this.grants.delete(grantKey(input.devicePodId, input.userId)); } async getDevicePod(id) { return this.devicePods.get(id) ?? null; } async listDevicePods() { return [...this.devicePods.values()].sort((a, b) => String(a.id).localeCompare(String(b.id))); } async hasDevicePodGrant(input = {}) { return this.grants.has(grantKey(input.devicePodId, input.userId)); } async listVisibleDevicePods(actor) { return [...this.devicePods.values()].filter((pod) => pod.status === "active" && (actor.role === "admin" || this.grants.has(grantKey(pod.id, actor.id)))); } async getVisibleDevicePod(actor, id) { return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; } 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 createDevicePodJob(input) { const job = normalizeJob(input); this.jobs.set(job.id, job); return job; } async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; } async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; } async listDevicePodJobs(devicePodId, limit) { return [...this.jobs.values()].filter((job) => job.devicePodId === devicePodId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); } 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) .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_device_pod_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 password_hash = $3, updated_at = $4 WHERE role = 'admin' AND (username = $1 OR id = $2) RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [input.username, input.id, input.passwordHash, input.now ?? this.now()]); return pgUser(result.rows?.[0]); } async createUser(input) { await this.ensureSchema(); const now = input.now ?? this.now(); const user = { id: input.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now }; const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]); return pgUser(result.rows?.[0]); } async createFirstAdmin(input) { await this.ensureSchema(); const now = input.now ?? this.now(); const user = { id: input.id, username: input.username, displayName: input.displayName ?? "", role: "admin", status: "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now }; const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) SELECT $1,$2,$3,$4,$5,$6,$7,$8 WHERE NOT EXISTS (SELECT 1 FROM users) ON CONFLICT DO NOTHING RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]); return pgUser(result.rows?.[0]); } async createSession(input) { await this.ensureSchema(); const session = await super.createSession(input); await this.query("INSERT INTO user_sessions (id, user_id, session_token_hash, created_at, last_seen_at, expires_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7)", [session.id, session.userId, session.tokenHash, session.createdAt, session.lastSeenAt, session.expiresAt, session.revokedAt]); return session; } async findSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); const result = await this.query("SELECT s.id, s.user_id, s.session_token_hash, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.username, u.display_name, u.role, u.status, u.password_hash, u.created_at AS user_created_at, u.updated_at AS user_updated_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 LIMIT 1", [tokenHash, now]); const row = result.rows?.[0]; return row ? pgSession(row) : null; } async touchSession(id, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET last_seen_at = $2 WHERE id = $1", [id, now]); } async revokeSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET revoked_at = $2 WHERE session_token_hash = $1", [tokenHash, now]); } async createApiKey(input) { await this.ensureSchema(); const key = await super.createApiKey(input); await this.query("INSERT INTO api_keys (id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, key_prefix = EXCLUDED.key_prefix, key_hash = EXCLUDED.key_hash, display_secret = EXCLUDED.display_secret, scopes_json = EXCLUDED.scopes_json, status = EXCLUDED.status, last_used_at = EXCLUDED.last_used_at, revoked_at = EXCLUDED.revoked_at", [key.id, key.userId, key.name, key.keyPrefix, key.keyHash, key.displaySecret, stableJson(key.scopes ?? []), key.status, key.createdAt, key.lastUsedAt, key.revokedAt]); return key; } async findApiKeyById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE id = $1 AND status = 'active' LIMIT 1", [id]); return pgApiKey(result.rows?.[0]); } async findApiKeyByPrefix(prefix) { await this.ensureSchema(); if (!prefix) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_prefix = $1 AND status = 'active' LIMIT 1", [prefix]); return pgApiKey(result.rows?.[0]); } async findApiKeyByHash(hash) { await this.ensureSchema(); if (!hash) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_hash = $1 AND status = 'active' LIMIT 1", [hash]); return pgApiKey(result.rows?.[0]); } async findApiKeyWithHashByPrefix(prefix) { await this.ensureSchema(); if (!prefix) return null; const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE key_prefix = $1 LIMIT 1", [prefix]); return pgApiKey(result.rows?.[0]); } async listApiKeysForUser(userId) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC", [userId]); return result.rows.map(pgApiKey); } async findActiveDefaultApiKeyForUser(userId) { await this.ensureSchema(); const result = await this.query("SELECT id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at FROM api_keys WHERE user_id = $1 AND name = $2 AND status = 'active' LIMIT 1", [userId, API_KEY_DEFAULT_NAME]); return pgApiKey(result.rows?.[0]); } async touchApiKey(id, now) { await this.ensureSchema(); await this.query("UPDATE api_keys SET last_used_at = $2 WHERE id = $1", [id, now]); } async revokeApiKey(id, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE id = $1 RETURNING id, user_id, name, key_prefix, key_hash, display_secret, scopes_json, status, created_at, last_used_at, revoked_at", [id, now]); return pgApiKey(result.rows?.[0]); } async revokeApiKeysForUser(userId, now) { await this.ensureSchema(); const result = await this.query("UPDATE api_keys SET status = 'revoked', revoked_at = $2 WHERE user_id = $1 AND status = 'active' RETURNING id", [userId, now]); return result.rows?.length ?? 0; } async upsertOidcState(input) { await this.ensureSchema(); await this.query("INSERT INTO oidc_states (state, nonce, return_to, created_at, expires_at) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (state) DO UPDATE SET nonce = EXCLUDED.nonce, return_to = EXCLUDED.return_to, created_at = EXCLUDED.created_at, expires_at = EXCLUDED.expires_at", [input.state, input.nonce, input.returnTo ?? "", input.createdAt, input.expiresAt]); return { ok: true }; } async consumeOidcState(state) { await this.ensureSchema(); const result = await this.query("DELETE FROM oidc_states WHERE state = $1 RETURNING state, nonce, return_to, created_at, expires_at", [state]); const row = result.rows?.[0]; if (!row) return null; return { state: row.state, nonce: row.nonce, returnTo: row.return_to, createdAt: row.created_at, expiresAt: row.expires_at }; } async findUserByKeycloakSubject(issuer, sub) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at FROM users WHERE keycloak_issuer = $1 AND keycloak_sub = $2 LIMIT 1", [issuer, sub]); return pgUserWithOIDC(result.rows?.[0]); } async createUserOidc(input) { await this.ensureSchema(); const id = `usr_${randomUUID()}`; const now = input.now ?? this.now(); const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, auth_provider = EXCLUDED.auth_provider, keycloak_issuer = EXCLUDED.keycloak_issuer, keycloak_sub = EXCLUDED.keycloak_sub, email = EXCLUDED.email, last_login_at = EXCLUDED.last_login_at, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [id, input.username, input.displayName ?? input.username, input.role ?? "user", input.status ?? "active", null, "keycloak", input.keycloakIssuer, input.keycloakSub, input.email ?? null, now, now, now]); return pgUserWithOIDC(result.rows?.[0]); } async updateUserOidcLogin(input) { await this.ensureSchema(); const result = await this.query("UPDATE users SET email = COALESCE($2, email), last_login_at = $3, updated_at = $3 WHERE id = $1 RETURNING id, username, display_name, role, status, password_hash, auth_provider, keycloak_issuer, keycloak_sub, email, last_login_at, created_at, updated_at", [input.userId, input.email ?? null, input.lastLoginAt]); return pgUserWithOIDC(result.rows?.[0]); } async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); } async grantDevicePod(input) { await this.ensureSchema(); const grant = await super.grantDevicePod(input); await this.query("INSERT INTO device_pod_grants (device_pod_id, user_id, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (device_pod_id, user_id) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [grant.devicePodId, grant.userId, grant.createdByAdminId, grant.createdAt]); return grant; } async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); } async getDevicePod(id) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods WHERE id = $1 LIMIT 1", [id]); return pgDevicePod(result.rows?.[0]); } async listDevicePods() { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods ORDER BY id", []); return result.rows.map(pgDevicePod); } async hasDevicePodGrant(input = {}) { await this.ensureSchema(); const result = await this.query("SELECT 1 FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2 LIMIT 1", [input.devicePodId, input.userId]); return Boolean(result.rows?.[0]); } async listVisibleDevicePods(actor) { await this.ensureSchema(); const sql = actor.role === "admin" ? "SELECT * FROM device_pods WHERE status = 'active' ORDER BY id" : "SELECT p.* FROM device_pods p JOIN device_pod_grants g ON g.device_pod_id = p.id WHERE p.status = 'active' AND g.user_id = $1 ORDER BY p.id"; const result = await this.query(sql, actor.role === "admin" ? [] : [actor.id]); return result.rows.map(pgDevicePod); } 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 createDevicePodJob(input) { await this.ensureSchema(); const job = normalizeJob(input); await this.query("INSERT INTO device_pod_jobs (id, device_pod_id, owner_user_id, status, intent, args_json, reason, trace_id, operation_id, output_json, blocker_json, created_at, updated_at, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", jobParams(job)); return job; } async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; } async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); } async listDevicePodJobs(devicePodId, limit) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 ORDER BY updated_at DESC LIMIT $2", [devicePodId, limit]); return result.rows.map(pgJob); } 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 ORDER BY updated_at DESC NULLS LAST LIMIT 1", [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_device_pod_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, selected_device_pod_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,$17) 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, selected_device_pod_id=$9, active_trace_id=$10, provider_profile=$11, workspace_json=$12, revision=$13, updated_by_session_id=$14, updated_by_client=$15, created_at=$16, updated_at=$17 WHERE id=$1 AND ($18::text = 'admin' OR owner_user_id=$2) RETURNING *", [...workspaceParams(workspace), textOr(input.actorRole, "user")]); return pgWorkspace(result.rows?.[0]); } } function parseDevicePodPath(pathname) { const prefix = "/v1/device-pods/"; if (!pathname.startsWith(prefix)) return null; const [devicePodId, ...rest] = pathname.slice(prefix.length).split("/").map((part) => decodeURIComponent(part)).filter(Boolean); return devicePodId ? { devicePodId, route: rest.join("/") } : null; } 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 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 firstString(...values) { return values.find((value) => typeof value === "string") ?? ""; } function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } function assertDevicePodProfileSafe(profile, field = "profile") { const findings = []; collectDevicePodProfileSecretFindings(profile, field, findings); if (findings.length > 0) { const paths = findings.slice(0, 3).join(", "); throw Object.assign(new Error(`${field} contains forbidden secret material at ${paths}`), { statusCode: 400, code: "device_pod_profile_secret_forbidden" }); } return profile; } function devicePodProfileForAuthority(devicePodId, profile) { return { ...assertDevicePodProfileSafe(profile, "profile"), devicePodId }; } function collectDevicePodProfileSecretFindings(value, path, findings) { if (findings.length >= 5) return; if (Array.isArray(value)) { value.forEach((item, index) => collectDevicePodProfileSecretFindings(item, `${path}[${index}]`, findings)); return; } if (value && typeof value === "object") { for (const [key, child] of Object.entries(value)) { const childPath = `${path}.${key}`; if (DEVICE_POD_PROFILE_SECRET_KEY_PATTERN.test(key)) findings.push(childPath); collectDevicePodProfileSecretFindings(child, childPath, findings); if (findings.length >= 5) return; } return; } if (typeof value === "string" && DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN.test(value)) findings.push(path); } function firstAdminDevicePodSeeds(body = {}) { const hasSingle = Object.hasOwn(body, "devicePod"); const hasMany = Object.hasOwn(body, "devicePods"); if (hasSingle && hasMany) throw Object.assign(new Error("Use devicePod or devicePods, not both"), { statusCode: 400, code: "invalid_params" }); if (!hasSingle && !hasMany) return []; const values = hasMany ? body.devicePods : [body.devicePod]; if (!Array.isArray(values)) throw Object.assign(new Error("devicePods must be an array"), { statusCode: 400, code: "invalid_params" }); return values.map((item, index) => firstAdminDevicePodSeed(item, hasSingle ? "devicePod" : `devicePods[${index}]`)); } function firstAdminDevicePodSeed(value, field) { if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error(`${field} must be an object`), { statusCode: 400, code: "invalid_params" }); const id = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`); const profile = normalizeObject(value.profile ?? value.profileJson); if (Object.keys(profile).length === 0) throw Object.assign(new Error(`${field}.profile is required`), { statusCode: 400, code: "invalid_params" }); assertDevicePodProfileSafe(profile, `${field}.profile`); return { id, name: textOr(value.name, id), status: value.status === "disabled" ? "disabled" : "active", profile }; } function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; } function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); } function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : fallback, 1), 60000); } function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } 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 devicePodApiKeyFromRequest(request) { return textOr(getHeader(request, DEVICE_POD_API_KEY_HEADER), ""); } function apiKeyFromRequest(request) { const auth = getHeader(request, "authorization"); if (!/^Bearer\s+/iu.test(String(auth ?? ""))) return ""; const token = String(auth).replace(/^Bearer\s+/iu, "").trim(); return isApiKeySecret(token) ? token : ""; } function devicePodInternalHeaders(serviceId, token) { return token ? { "x-hwlab-internal-service": serviceId, "x-hwlab-internal-token": token } : { "x-hwlab-internal-service": serviceId }; } 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 constantSecretEquals(a, b) { const left = sha256(a); const right = sha256(b); return left === right; } 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 profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } function accessTupleKey(tuple = {}) { return `${tuple.userId}\u0000${tuple.relation}\u0000${tuple.object}`; } function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } 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", devicePods: "/v1/device-pods" }; } function normalizeToolId(value) { return textOr(value, "").replace(/-/gu, "_"); } function _deviceJobRequiresReason(intent, args, reason) { if (!MUTATING_INTENTS.has(intent)) return false; if (reason) return false; if (!DEVICE_JOB_ACTIONABLE_INTENTS.has(intent)) return true; const action = typeof args?.action === "string" ? args.action.trim().toLowerCase() : ""; if (action && DEVICE_JOB_READ_ONLY_SUB_ACTIONS.has(action)) return false; return true; } function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; } function authorizationBlocker(code, summary, authorization) { return { code, layer: "authorization", retryable: authorization?.degradedReason ? true : false, summary, userMessage: summary, authorization }; } function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; } function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(status, ""); return ["queued", "running", "completed", "failed", "blocked", "canceled"].includes(text) ? text : httpStatus >= 400 ? "blocked" : "running"; } function executorOutputPayload(body, httpStatus) { const output = normalizeObject(body?.output); const nestedOutput = normalizeObject(output.output); const text = firstString( body?.text, output.text, nestedOutput.text, output.summary, nestedOutput.summary, normalizeObject(nestedOutput.evidence).text, normalizeObject(output.evidence).text, normalizeObject(nestedOutput.evidence).logTail, normalizeObject(output.evidence).logTail, normalizeObject(nestedOutput.evidence).summary, normalizeObject(output.evidence).summary ); return { executor: body ?? {}, output: Object.keys(nestedOutput).length > 0 ? nestedOutput : output, text, httpStatus }; } function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) { const source = normalizeObject(output); const text = typeof source.text === "string" ? source.text : stableJson(source); const buffer = Buffer.from(text, "utf8"); const clipped = buffer.length > maxBytes; const boundedText = clipped ? buffer.subarray(0, maxBytes).toString("utf8") : text; const bounded = { ...source, text: boundedText }; if (clipped) { delete bounded.executor; delete bounded.output; bounded.omitted = { reason: "device_job_output_truncated", originalBytes: buffer.length }; } return { output: bounded, text: boundedText, bytes: Math.min(buffer.length, maxBytes), truncation: { maxBytes, truncated: clipped, originalBytes: buffer.length } }; } function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "cloud-api" }; } 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_DEVICE_POD_API_KEY].includes(method) ? method : AUTH_METHOD_WEB_SESSION; } function publicApiKey(key, { includeSecret = false } = {}) { if (!key) return null; return { id: key.id, userId: key.userId, name: key.name, keyPrefix: key.keyPrefix, scopes: Array.isArray(key.scopes) ? key.scopes : [], status: key.status, createdAt: key.createdAt, lastUsedAt: key.lastUsedAt ?? null, revokedAt: key.revokedAt ?? null, ...(includeSecret && key.displaySecret ? { displaySecret: key.displaySecret } : {}) }; } function redactedUser(user) { return publicActor(user); } function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; } function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePodId: pod.id, name: pod.name, status: pod.status, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: redactedProfile(pod.profile), createdAt: pod.createdAt, updatedAt: pod.updatedAt, blocker: null, ...(includeAdmin ? { admin: { profileStored: true, routeStored: Boolean(pod.profile.route) } } : {}) }; } 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 devicePodMatrixFromTuples(tuples = []) { const byPod = new Map(); for (const tuple of tuples) { const parsed = parseOpenFgaObject(tuple.object); if (parsed?.type !== "device_pod") continue; const current = byPod.get(parsed.id) ?? Object.fromEntries(DEVICE_POD_RELATIONS.map((relation) => [relation, false])); if (DEVICE_POD_RELATIONS.includes(tuple.relation)) current[tuple.relation] = true; byPod.set(parsed.id, current); } return [...byPod.entries()].map(([devicePodId, relations]) => ({ devicePodId, relations })).sort((a, b) => a.devicePodId.localeCompare(b.devicePodId)); } function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; } function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; } function terminalJobStatus(status) { return ["completed", "failed", "blocked", "canceled"].includes(status); } function shouldReturnDevicePodCreateOutput(job) { return terminalJobStatus(job?.status) && job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; } function shouldSynchronouslyWaitForDevicePodJob(job) { return job?.intent === "workspace.evidence" && textOr(normalizeObject(job.args).kind, "") === "verify"; } function devicePodExecutorCreateTimeoutMs(controller, job) { return shouldSynchronouslyWaitForDevicePodJob(job) ? controller.devicePodSyncTimeoutMs : controller.devicePodExecutorTimeoutMs; } function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, ownerUserId: job.ownerUserId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt }; } function jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; } function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); } function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; } function normalizeAgentSessionOwnerRecord(input, existing, now) { 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: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: mergeAgentSessionOwnerEvidence(input.session, existing?.session), updatedAt: now }; } function mergeAgentSessionOwnerEvidence(nextValue, existingValue) { const existing = normalizeObject(existingValue); const next = normalizeObject(nextValue); const merged = { ...existing, ...next }; 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; } 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 nextCount = numberOrNull(next.messageCount); const existingCount = numberOrNull(existing.messageCount); 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 mergeAgentSessionOwnerMessages(nextMessages = [], existingMessages = [], context = {}) { const next = Array.isArray(nextMessages) ? nextMessages.map(redactConversationMessage).filter(Boolean) : []; if (next.some((message) => message.role === "user")) return next; const existing = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : []; const merged = [...existing]; for (const message of next) { const index = merged.findIndex((item) => sameConversationMessage(item, message)); if (index >= 0) merged[index] = message; else merged.push(message); } return 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 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, selectedDevicePodId: textOr(body.selectedDevicePodId ?? body.devicePodId ?? workspace.selectedDevicePodId, workspace.selectedDevicePodId ?? null), activeTraceId: safeTraceIdLocal(body.activeTraceId ?? body.traceId ?? workspace.activeTraceId) || null, providerProfile: textOr(body.providerProfile ?? workspace.providerProfile, workspace.providerProfile ?? null), messages: Array.isArray(body.messages) ? body.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : Array.isArray(workspace.messages) ? 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_device_pod_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), selectedDevicePodId: nullableText(input.selectedDevicePodId, existing?.selectedDevicePodId), 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, selectedDevicePod = 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, selectedDevicePodId: workspace.selectedDevicePodId, activeTraceId: workspace.activeTraceId, providerProfile: workspace.providerProfile, selectedConversation: conversation, selectedDevicePod: selectedDevicePod ? publicDevicePod(selectedDevicePod) : null, 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, ""), selectedDevicePodId: textOr(workspace.selectedDevicePodId, ""), 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) ? workspace.messages.slice(-50).map(redactConversationMessage).filter(Boolean) : 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.selectedDevicePodId, 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; return { ...snapshot, messages: Array.isArray(messagesSource) ? messagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : [], chatMessages: Array.isArray(chatMessagesSource) ? chatMessagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined, messageCount: resolveSnapshotMessageCount(messagesSource, snapshot.messageCount), firstUserMessagePreview: firstUserPreviewFromMessages(messagesSource) ?? 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 (explicit !== null && fromSource !== null) return Math.max(explicit, fromSource); return explicit ?? fromSource ?? 0; } function firstUserPreviewFromMessages(messagesSource) { if (!Array.isArray(messagesSource)) return null; for (const message of messagesSource) { if (!message || typeof message !== "object") continue; if (String(message.role ?? "").toLowerCase() !== "user") continue; const text = textOr(message.text ?? message.title ?? message.content, ""); if (text) return boundedText(text, 240); } return null; } function numberOrNull(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : null; } function isTerminalCodeAgentResult(result = {}) { const status = textOr(result.status, "").toLowerCase(); const terminalStatus = textOr(result.agentRun?.terminalStatus, "").toLowerCase(); return [status, terminalStatus].some((value) => ["completed", "failed", "blocked", "canceled", "cancelled", "timeout", "error"].includes(value)); } function isThreadResumeFailedResult(result = {}) { const values = [ result.error?.code, result.error?.category, result.blocker?.code, result.blocker?.category, result.providerTrace?.failureKind, result.agentRun?.providerTrace?.failureKind, result.agentRun?.failureKind ].map((value) => textOr(value, "").toLowerCase().replace(/_/gu, "-")); return values.some((value) => value === "thread-resume-failed") || /no rollout found for thread id|thread\/resume failed/iu.test(String(result.error?.message ?? result.blocker?.message ?? result.providerTrace?.failureMessage ?? result.agentRun?.providerTrace?.failureMessage ?? "")); } function threadResumeFailureThreadId(result = {}) { return boundedText(textOr( result.providerTrace?.threadId ?? result.agentRun?.providerTrace?.threadId ?? result.session?.threadId ?? result.sessionReuse?.threadId ?? result.threadId, "" ), 240) || null; } function terminalWorkbenchSessionStatus(result = {}) { if (isThreadResumeFailedResult(result)) return "failed"; const sessionStatus = textOr(result.session?.status ?? result.sessionSummary?.status ?? result.sessionLifecycleStatus ?? result.runnerTrace?.sessionStatus, "").toLowerCase(); if (sessionStatus && sessionStatus !== "running" && sessionStatus !== "busy" && sessionStatus !== "pending") return sessionStatus === "cancelled" ? "canceled" : sessionStatus; const status = textOr(result.status ?? result.agentRun?.terminalStatus, "").toLowerCase(); if (status === "completed") return "idle"; if (status === "cancelled") return "canceled"; return status || "idle"; } function canActorReadWorkspaceTrace(result = {}, actor = null) { const ownerUserId = textOr(result.ownerUserId, ""); if (!ownerUserId || actor?.role === "admin") return true; return ownerUserId === actor?.id; } function conversationNeedsTerminalRepair(conversation, traceId) { if (!conversation || !safeTraceIdLocal(traceId)) return false; const status = textOr(conversation.status, "").toLowerCase(); if (["running", "busy", "pending", "active"].includes(status)) return true; const messages = Array.isArray(conversation.messages) ? conversation.messages : []; const traceMessage = messages.find((message) => message?.traceId === traceId && message?.role === "agent") ?? null; const latestAgent = latestAgentConversationMessage(messages); if (!traceMessage) return Boolean(latestAgent && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)); if (latestAgent?.traceId && latestAgent.traceId !== traceId && traceId === safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId)) return true; const messageStatus = textOr(traceMessage.status, "").toLowerCase(); return ["", "running", "busy", "pending", "active"].includes(messageStatus) || isLegacyFinalResponseFallbackText(traceMessage.text); } function terminalConversationRepairTraceId(conversation) { if (!conversation) return ""; const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId); if (lastTraceId && conversationNeedsTerminalRepair(conversation, lastTraceId)) return lastTraceId; const messages = Array.isArray(conversation.messages) ? conversation.messages : []; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; const traceId = safeTraceIdLocal(message?.traceId); if (message?.role === "agent" && traceId && conversationNeedsTerminalRepair(conversation, traceId)) return traceId; } return ""; } function terminalConversationFallbackRepairTraceId(conversation) { if (!conversation) return ""; const messages = Array.isArray(conversation.messages) ? conversation.messages : []; const lastTraceId = safeTraceIdLocal(conversation.lastTraceId ?? conversation.session?.lastTraceId ?? conversation.snapshot?.lastTraceId); const lastTraceMessage = messages.find((message) => message?.traceId === lastTraceId && message?.role === "agent") ?? null; const latestAgent = latestAgentConversationMessage(messages); if (lastTraceId && latestAgent?.traceId && latestAgent.traceId !== lastTraceId) return lastTraceId; if (lastTraceId && latestAgent && !lastTraceMessage) return lastTraceId; if (lastTraceId && isLegacyFinalResponseFallbackText(lastTraceMessage?.text)) return lastTraceId; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; const traceId = safeTraceIdLocal(message?.traceId); if (message?.role === "agent" && traceId && isLegacyFinalResponseFallbackText(message.text)) return traceId; } return ""; } function isLegacyFinalResponseFallbackText(value) { return textOr(value, "").trim() === "Code Agent 仍在处理,可以继续 steer 或等待 trace 完成。"; } function mergeTerminalConversationMessages(existingMessages = [], result = {}, context = {}) { const baseMessages = Array.isArray(existingMessages) ? existingMessages.map(redactConversationMessage).filter(Boolean) : []; 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 = ensureTerminalUserMessage(baseMessages, { ...context, traceId, now, firstUserPreview }); const existingCount = numberOrNull(context.existingMessageCount) ?? messages.length; 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 = next.slice(-50); return { messages: finalMessages, messageCount: Math.max(existingCount + (addedAgentMessage ? 1 : 0), 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 }); } 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 }) : 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 = ensureTerminalUserMessage(rawMessages, { traceId: session.lastTraceId ?? snapshot.lastTraceId, conversationId: session.conversationId, sessionId: session.id, threadId: session.threadId, now: session.updatedAt, firstUserPreview: snapshot.firstUserMessagePreview }); const status = resolvedConversationStatus(session.status, snapshot, messages); const lastTraceId = resolvedConversationLastTraceId(session.lastTraceId, status, messages, 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, messageCount: numberOrNull(snapshot.messageCount) ?? messages.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 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 pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; } function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: normalizeBlocker(parseJson(row.blocker_json, null)), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } 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, selectedDevicePodId: row.selected_device_pod_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 jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; } 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; } async function fetchJsonWithTimeout(fetchImpl, url, options, timeoutMs) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetchImpl(url, { ...options, signal: controller.signal }); const text = await response.text(); return { status: response.status, body: parseJson(text, {}) }; } finally { clearTimeout(timeout); } }