diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index ad5509d7..a1a2706a 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -163,6 +163,9 @@ POST /v1/device-pods/{devicePodId}/jobs GET /v1/device-pods/{devicePodId}/jobs/{jobId} GET /v1/device-pods/{devicePodId}/jobs/{jobId}/output POST /v1/device-pods/{devicePodId}/jobs/{jobId}/cancel +POST /v1/device-pods/{devicePodId}/leases +GET /v1/device-pods/{devicePodId}/leases/current +DELETE /v1/device-pods/{devicePodId}/leases/current ``` 管理员 API 由 `cloud-api` 提供: @@ -242,6 +245,8 @@ manages: many devicePodId - `hwlab-device-pod` 一个实例可以列出并执行多个 `devicePodId` 的状态/job。 - cloud-api compatibility fallback 只能返回 blocked authority payload,不得合成 fake device pod 数据或作为正式 device-pod DEV-LIVE 证据。 - 强副作用 job 必须有 reason,并在物理互斥需要时获取 `device_leases`。 +- `POST /v1/device-pods/{devicePodId}/leases` 只对已授权 actor 创建或刷新互斥租约,响应只返回一次性 `leaseToken`;后续强副作用 job 必须通过 `leaseToken` 或 `x-hwlab-device-lease-token` 证明持有租约。 +- 撤销 device pod grant 必须释放该用户对同一 `devicePodId` 的活动 lease。 ## 测试规格 @@ -262,8 +267,8 @@ manages: many devicePodId | 规格项 | 状态 | 说明 | | --- | --- | --- | | 逻辑 device-pod 模型 | 已实现为规格 | 四要素、profile shape 和 Kubernetes 口径已定义。 | -| profile server authority | 未完全实现 | 当前 v02 device-pod 服务未读取正式 DB profile。 | -| 用户 grant + lease | 未完全实现 | 与 spec-user-access 配套,仍待完整实现。 | -| REST/job API | 未完全实现 | 当前只读/fake device-pod payload 已部署,正式 job lifecycle 未完成。 | +| profile server authority | 部分实现 | cloud-api 保存正式 DB profile 并向用户返回脱敏摘要;device-pod executor 不接受用户上传 profile。 | +| 用户 grant + lease | 部分实现 | cloud-api 已实现 admin grant、可见性过滤、lease acquire/current/release、强副作用 job lease 校验和撤销授权释放 lease。 | +| REST/job API | 部分实现 | cloud-api 已实现 list/status/events/probe/job/output/cancel 和 lease API;无在线 gateway/device-host-cli 时返回 blocker。 | | 禁止 fake 作为 DEV-LIVE | 已实现/持续约束 | 规格和服务 payload 要求显式标记 fake/source。 | diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 653758d2..839d9acf 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -277,6 +277,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device | `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 | | `POST /v1/admin/device-pod-grants`、`DELETE /v1/admin/device-pod-grants/{devicePodId}/{userId}` | admin 授权或撤销普通用户使用 device pod。 | | `GET /v1/device-pods` 和 device-pod 操作 API | 按 actor role 和 grant 过滤可见/可用 device pod。 | +| `POST /v1/device-pods/{devicePodId}/leases`、`GET/DELETE /v1/device-pods/{devicePodId}/leases/current` | 对已授权 device pod 获取、查看和释放互斥 lease;强副作用 job 必须携带有效 lease token。 | | `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 | ## 微服务设计 @@ -331,7 +332,7 @@ Kubernetes 只做运行时隔离和资源兜底,不承载 HWLAB 用户权限 | 规格项 | 状态 | 说明 | | --- | --- | --- | | admin/user 两角色模型 | 部分实现 | cloud-api 已实现 `/auth/*`、bootstrap admin、admin/user 创建和 admin-only 路由。 | -| `users`、`user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobs;lease 互斥流程仍待接入真实执行链路。 | +| `users`、`user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobs;Device Pod 强副作用 job 已接入 lease token 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 | | Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidence;trace/result cache 也按 owner/admin 限制访问。 | | device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 | | 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index d53e7880..5cb14ca5 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -29,6 +29,12 @@ test("cloud api access control grants visible device pods and blocks unavailable assert.equal(userCreate.status, 201); assert.equal(userCreate.body.user.username, "alice"); assert.equal(JSON.stringify(userCreate.body).includes("alice-pass"), false); + const bobCreate = await postJson(port, "/v1/admin/users", { + username: "bob", + password: "bob-pass", + displayName: "Bob" + }, adminCookie); + assert.equal(bobCreate.status, 201); const podCreate = await postJson(port, "/v1/admin/device-pods", { devicePodId: "device-pod-71-freq", @@ -61,6 +67,11 @@ test("cloud api access control grants visible device pods and blocks unavailable userId: userCreate.body.user.id }, adminCookie); assert.equal(grant.status, 201); + const bobGrant = await postJson(port, "/v1/admin/device-pod-grants", { + devicePodId: "device-pod-71-freq", + userId: bobCreate.body.user.id + }, adminCookie); + assert.equal(bobGrant.status, 201); const visible = await getJson(port, "/v1/device-pods", aliceCookie); assert.equal(visible.status, 200); @@ -81,6 +92,70 @@ test("cloud api access control grants visible device pods and blocks unavailable assert.equal(job.body.devicePodId, "device-pod-71-freq"); assert.equal(job.body.profileHash, visible.body.devicePods[0].profileHash); + const mutatingWithoutLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { + intent: "debug.reset", + reason: "reset smoke" + }, aliceCookie); + assert.equal(mutatingWithoutLease.status, 409); + assert.equal(mutatingWithoutLease.body.error.code, "device_lease_required"); + + const lease = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { + reason: "exclusive reset smoke", + ttlSeconds: 60 + }, aliceCookie); + assert.equal(lease.status, 201); + assert.equal(lease.body.contractVersion, "device-pod-lease-v1"); + assert.equal(lease.body.lease.devicePodId, "device-pod-71-freq"); + assert.equal(lease.body.lease.holderUserId, userCreate.body.user.id); + assert.equal(typeof lease.body.leaseToken, "string"); + assert.equal(JSON.stringify(lease.body.lease).includes(lease.body.leaseToken), false); + + const currentLease = await getJson(port, "/v1/device-pods/device-pod-71-freq/leases/current", aliceCookie); + assert.equal(currentLease.status, 200); + assert.equal(currentLease.body.lease.holderUserId, userCreate.body.user.id); + + const releaseWithoutToken = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-71-freq/leases/current`, { + method: "DELETE", + headers: { cookie: aliceCookie } + }); + assert.equal(releaseWithoutToken.status, 404); + assert.equal((await releaseWithoutToken.json()).released, false); + const currentAfterMissingToken = await getJson(port, "/v1/device-pods/device-pod-71-freq/leases/current", aliceCookie); + assert.equal(currentAfterMissingToken.status, 200); + assert.equal(currentAfterMissingToken.body.lease.holderUserId, userCreate.body.user.id); + + const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); + assert.equal(bobLogin.status, 200); + const bobLeaseConflict = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { + reason: "exclusive reset smoke" + }, bobLogin.cookie); + assert.equal(bobLeaseConflict.status, 409); + assert.equal(bobLeaseConflict.body.error.code, "device_lease_conflict"); + assert.equal(bobLeaseConflict.body.lease.holderUserId, userCreate.body.user.id); + + const mutatingWithLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { + intent: "debug.reset", + reason: "reset smoke with lease", + leaseToken: lease.body.leaseToken + }, aliceCookie); + assert.equal(mutatingWithLease.status, 409); + assert.equal(mutatingWithLease.body.status, "blocked"); + assert.equal(mutatingWithLease.body.lease.holderUserId, userCreate.body.user.id); + assert.equal(mutatingWithLease.body.blocker.code, "gateway_dispatch_unavailable"); + + const release = await fetch(`http://127.0.0.1:${port}/v1/device-pods/device-pod-71-freq/leases/current`, { + method: "DELETE", + headers: { cookie: aliceCookie, "x-hwlab-device-lease-token": lease.body.leaseToken } + }); + assert.equal(release.status, 200); + assert.equal((await release.json()).released, true); + + const bobLease = await postJson(port, "/v1/device-pods/device-pod-71-freq/leases", { + reason: "exclusive reset smoke after release" + }, bobLogin.cookie); + assert.equal(bobLease.status, 201); + assert.equal(bobLease.body.lease.holderUserId, bobCreate.body.user.id); + const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceCookie); assert.equal(events.status, 200); assert.equal(events.body.events[0].blocker.code, "gateway_dispatch_unavailable"); @@ -92,16 +167,18 @@ test("cloud api access control grants visible device pods and blocks unavailable assert.equal(unsupported.status, 400); assert.equal(unsupported.body.error.code, "unsupported_device_job_intent"); - const revoke = await fetch(`http://127.0.0.1:${port}/v1/admin/device-pod-grants/device-pod-71-freq/${userCreate.body.user.id}`, { + const revoke = await fetch(`http://127.0.0.1:${port}/v1/admin/device-pod-grants/device-pod-71-freq/${bobCreate.body.user.id}`, { method: "DELETE", headers: { cookie: adminCookie } }); assert.equal(revoke.status, 200); const revoked = await revoke.json(); assert.equal(revoked.devicePodId, "device-pod-71-freq"); - assert.equal(revoked.userId, userCreate.body.user.id); + assert.equal(revoked.userId, bobCreate.body.user.id); + assert.equal(revoked.releasedLease.holderUserId, bobCreate.body.user.id); + assert.equal(revoked.releasedLease.active, false); - const afterRevoke = await getJson(port, "/v1/device-pods", aliceCookie); + const afterRevoke = await getJson(port, "/v1/device-pods", bobLogin.cookie); assert.equal(afterRevoke.status, 200); assert.deepEqual(afterRevoke.body.devicePods, []); } finally { diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index e6f78c9c..c393048b 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -113,6 +113,8 @@ const MUTATING_INTENTS = new Set([ "debug.reset", "io.uart.write" ]); +const DEFAULT_DEVICE_LEASE_TTL_SECONDS = 15 * 60; +const MAX_DEVICE_LEASE_TTL_SECONDS = 60 * 60; export function createAccessController(options = {}) { return new AccessController({ @@ -288,7 +290,8 @@ class AccessController { 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") }); - return sendJson(response, 200, { revoked: true, devicePodId, userId }); + const releasedLease = await this.store.releaseDeviceLease?.({ devicePodId, holderUserId: userId, now: this.now(), allowAdmin: false, revokeGrant: true }); + return sendJson(response, 200, { revoked: true, devicePodId, userId, releasedLease: publicDeviceLease(releasedLease) }); } sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404)); @@ -327,6 +330,9 @@ class AccessController { if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return sendJson(response, 200, this.devicePodProbe(pod, "debug.chip-id")); if (request.method === "GET" && parsed.route === "io-probe/uart/1") return sendJson(response, 200, this.devicePodProbe(pod, "io.uart.status")); if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return sendJson(response, 200, this.devicePodOutputProbe(pod, url.searchParams)); + if (request.method === "POST" && parsed.route === "leases") return this.acquireDevicePodLease(request, response, pod, auth.actor); + if (request.method === "GET" && parsed.route === "leases/current") return this.getDevicePodLease(response, pod, auth.actor); + if (request.method === "DELETE" && parsed.route === "leases/current") return this.releaseDevicePodLease(request, response, pod, auth.actor); 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); @@ -430,6 +436,100 @@ class AccessController { } } + async acquireDevicePodLease(request, response, pod, actor) { + const body = await jsonBody(request); + const now = this.now(); + const ttlSeconds = boundedLeaseTtlSeconds(body.ttlSeconds); + const expiresAt = new Date(Date.parse(now) + ttlSeconds * 1000).toISOString(); + const token = randomBytes(32).toString("base64url"); + const holderSessionId = safeAgentSessionId(body.agentSessionId) || `ses_devicepod_${randomUUID()}`; + const lease = await this.store.acquireDeviceLease?.({ + devicePodId: pod.id, + holderSessionId, + holderUserId: actor.id, + leaseTokenHash: sha256(token), + now, + expiresAt + }); + if (!lease) { + const current = await this.store.getActiveDeviceLease?.(pod.id, now); + return sendJson(response, 409, { + ok: false, + status: 409, + error: { + code: "device_lease_conflict", + message: "Device Pod already has an active lease held by another actor" + }, + lease: publicDeviceLease(current), + devicePodId: pod.id, + profileHash: pod.profileHash + }); + } + await this.store.recordAgentSessionOwner?.({ + sessionId: holderSessionId, + ownerUserId: actor.id, + projectId: textOr(body.projectId, "prj_v02_device_pod"), + agentId: "hwlab-device-pod", + status: "active", + session: { source: "device-pod-lease", reason: textOr(body.reason, "") }, + now + }); + return sendJson(response, 201, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-lease-v1", + acquired: true, + devicePodId: pod.id, + targetId: targetIdFromProfile(pod.profile), + profileHash: pod.profileHash, + lease: publicDeviceLease(lease), + leaseToken: token, + tokenUsage: "Pass as body.leaseToken or x-hwlab-device-lease-token when creating mutating jobs.", + source: authoritySource() + }); + } + + async getDevicePodLease(response, pod, actor) { + const lease = await this.store.getActiveDeviceLease?.(pod.id, this.now()); + if (!lease || (actor.role !== "admin" && lease.holderUserId !== actor.id)) { + return sendJson(response, 200, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-lease-v1", + devicePodId: pod.id, + profileHash: pod.profileHash, + lease: null, + source: authoritySource() + }); + } + return sendJson(response, 200, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-lease-v1", + devicePodId: pod.id, + profileHash: pod.profileHash, + lease: publicDeviceLease(lease), + source: authoritySource() + }); + } + + async releaseDevicePodLease(request, response, pod, actor) { + const token = leaseTokenFromRequest(request, null); + const released = await this.store.releaseDeviceLease?.({ + devicePodId: pod.id, + holderUserId: actor.id, + tokenHash: token ? sha256(token) : null, + now: this.now(), + allowAdmin: actor.role === "admin" + }); + return sendJson(response, released ? 200 : 404, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-lease-v1", + released: Boolean(released), + devicePodId: pod.id, + profileHash: pod.profileHash, + lease: publicDeviceLease(released), + source: authoritySource() + }); + } + devicePodStatus(pod, actor) { const route = pod.profile.route ?? {}; const gatewaySessionId = textOr(route.gatewaySessionId, ""); @@ -517,6 +617,17 @@ class AccessController { if (MUTATING_INTENTS.has(intent) && !reason) { return sendJson(response, 400, errorPayload("device_job_reason_required", `Device job intent ${intent} requires reason`, 400)); } + let lease = null; + if (MUTATING_INTENTS.has(intent)) { + const leaseToken = leaseTokenFromRequest(request, body); + if (!leaseToken) { + return sendJson(response, 409, errorPayload("device_lease_required", `Device job intent ${intent} requires an active device lease`, 409)); + } + lease = await this.store.getDeviceLeaseByToken?.({ devicePodId: pod.id, tokenHash: sha256(leaseToken), now: this.now() }); + if (!lease || (lease.holderUserId !== actor.id && actor.role !== "admin")) { + return sendJson(response, 409, errorPayload("device_lease_invalid", "Device lease is missing, expired, released, or held by another actor", 409)); + } + } const traceId = `trc_devicepod_${randomUUID()}`; const operationId = `op_devicepod_${randomUUID()}`; const now = this.now(); @@ -541,7 +652,7 @@ class AccessController { }); if (!blocker) this.dispatchDevicePodJob({ job, pod, actor }); - sendJson(response, blocker ? 409 : 202, this.jobPayload(job, pod)); + sendJson(response, blocker ? 409 : 202, this.jobPayload(job, pod, { lease })); } async getDevicePodJob(response, pod, route, actor) { @@ -609,7 +720,7 @@ class AccessController { }); } - jobPayload(job, pod) { + jobPayload(job, pod, { lease = null } = {}) { return { serviceId: CLOUD_API_SERVICE_ID, contractVersion: DEVICE_JOB_CONTRACT_VERSION, @@ -621,6 +732,7 @@ class AccessController { traceId: job.traceId, operationId: job.operationId, job: publicJob(job), + lease: lease ? publicDeviceLease(lease) : null, blocker: job.blocker, freshness: freshness(job.updatedAt, job.blocker), outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, @@ -647,6 +759,7 @@ class MemoryAccessStore { this.sessions = new Map(); this.devicePods = new Map(); this.grants = new Set(); + this.leases = new Map(); this.jobs = new Map(); this.agentSessions = new Map(); } @@ -685,6 +798,32 @@ class MemoryAccessStore { async getDevicePod(id) { return this.devicePods.get(id) ?? null; } 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 acquireDeviceLease(input) { + const now = input.now ?? this.now(); + const active = await this.getActiveDeviceLease(input.devicePodId, now); + if (active && active.holderUserId !== input.holderUserId) return null; + const lease = { devicePodId: input.devicePodId, holderSessionId: input.holderSessionId, holderUserId: input.holderUserId, leaseTokenHash: input.leaseTokenHash, createdAt: active?.createdAt ?? now, expiresAt: input.expiresAt, releasedAt: null }; + this.leases.set(input.devicePodId, lease); + return lease; + } + async getActiveDeviceLease(devicePodId, now = this.now()) { + const lease = this.leases.get(devicePodId) ?? null; + return lease && !lease.releasedAt && lease.expiresAt > now ? lease : null; + } + async getDeviceLeaseByToken(input) { + const lease = await this.getActiveDeviceLease(input.devicePodId, input.now ?? this.now()); + return lease?.leaseTokenHash === input.tokenHash ? lease : null; + } + async releaseDeviceLease(input) { + const lease = await this.getActiveDeviceLease(input.devicePodId, input.now ?? this.now()); + if (!lease) return null; + const tokenMatches = input.tokenHash && lease.leaseTokenHash === input.tokenHash; + if (!input.allowAdmin && !input.revokeGrant && (lease.holderUserId !== input.holderUserId || !tokenMatches)) return null; + if (!input.allowAdmin && input.revokeGrant && lease.holderUserId !== input.holderUserId) return null; + const released = { ...lease, releasedAt: input.now ?? this.now() }; + this.leases.set(input.devicePodId, released); + return released; + } 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; } @@ -731,6 +870,30 @@ class PostgresAccessStore extends MemoryAccessStore { 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 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 acquireDeviceLease(input) { + await this.ensureSchema(); + const active = await this.getActiveDeviceLease(input.devicePodId, input.now); + if (active && active.holderUserId !== input.holderUserId) return null; + const result = await this.query("INSERT INTO device_leases (device_pod_id, holder_session_id, holder_user_id, lease_token_hash, created_at, expires_at, released_at) VALUES ($1,$2,$3,$4,$5,$6,NULL) ON CONFLICT (device_pod_id) DO UPDATE SET holder_session_id = EXCLUDED.holder_session_id, holder_user_id = EXCLUDED.holder_user_id, lease_token_hash = EXCLUDED.lease_token_hash, expires_at = EXCLUDED.expires_at, released_at = NULL WHERE device_leases.released_at IS NOT NULL OR device_leases.expires_at <= $5 OR device_leases.holder_user_id = $3 RETURNING *", [input.devicePodId, input.holderSessionId, input.holderUserId, input.leaseTokenHash, input.now, input.expiresAt]); + return pgLease(result.rows?.[0]); + } + async getActiveDeviceLease(devicePodId, now = this.now()) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_leases WHERE device_pod_id = $1 AND released_at IS NULL AND expires_at > $2 LIMIT 1", [devicePodId, now]); return pgLease(result.rows?.[0]); } + async getDeviceLeaseByToken(input) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_leases WHERE device_pod_id = $1 AND lease_token_hash = $2 AND released_at IS NULL AND expires_at > $3 LIMIT 1", [input.devicePodId, input.tokenHash, input.now]); return pgLease(result.rows?.[0]); } + async releaseDeviceLease(input) { + await this.ensureSchema(); + const params = input.allowAdmin + ? [input.devicePodId, input.now] + : input.revokeGrant + ? [input.devicePodId, input.now, input.holderUserId] + : [input.devicePodId, input.now, input.holderUserId, input.tokenHash]; + const sql = input.allowAdmin + ? "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND released_at IS NULL RETURNING *" + : input.revokeGrant + ? "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND holder_user_id = $3 AND released_at IS NULL RETURNING *" + : "UPDATE device_leases SET released_at = $2 WHERE device_pod_id = $1 AND holder_user_id = $3 AND lease_token_hash = $4 AND released_at IS NULL RETURNING *"; + const result = await this.query(sql, params); + return pgLease(result.rows?.[0]); + } 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]); } @@ -759,6 +922,9 @@ async function jsonBody(request) { function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; } function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } +function boundedLeaseTtlSeconds(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DEVICE_LEASE_TTL_SECONDS, 1), MAX_DEVICE_LEASE_TTL_SECONDS); } +function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } +function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; } function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); } function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); } @@ -773,6 +939,7 @@ function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; } 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 publicDeviceLease(lease) { return lease ? { devicePodId: lease.devicePodId, holderSessionId: lease.holderSessionId, holderUserId: lease.holderUserId, createdAt: lease.createdAt, expiresAt: lease.expiresAt, releasedAt: lease.releasedAt ?? null, active: !lease.releasedAt } : null; } 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 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; } @@ -793,6 +960,7 @@ function sendAccessError(response, error) { const status = error?.statusCode ?? 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 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 pgLease(row) { return row ? { devicePodId: row.device_pod_id, holderSessionId: row.holder_session_id, holderUserId: row.holder_user_id, leaseTokenHash: row.lease_token_hash, createdAt: row.created_at, expiresAt: row.expires_at, releasedAt: row.released_at } : null; } 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: 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 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]; }