feat: enforce device pod leases

This commit is contained in:
Codex
2026-05-29 09:36:41 +08:00
parent 8e83c4f715
commit 1eff0ec472
4 changed files with 261 additions and 10 deletions
+8 -3
View File
@@ -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。 |
+2 -1
View File
@@ -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 和 jobslease 互斥流程仍待接入真实执行链路。 |
| `users``user_sessions`、grant/lease 表 | 部分实现 | 0001 schema 和 access-control bootstrap 覆盖 users、sessions、device_pods、grants、leases 和 jobsDevice Pod 强副作用 job 已接入 lease token 校验,真实硬件执行仍依赖 gateway/device-host-cli 在线。 |
| Code Agent owner 绑定 | 已实现 | 已在 `agent_sessions` 写入 `owner_user_id`、conversation/thread/trace 和脱敏 session evidencetrace/result cache 也按 owner/admin 限制访问。 |
| device pod 授权模型 | 部分实现 | cloud-api 已实现 admin profile/grant、普通用户可见性和 job 持久化;无在线 gateway/device-host-cli 时返回 blocker。 |
| 不用 Kubernetes 表达用户权限 | 已实现/持续约束 | 规格明确禁止普通用户持有 kubeconfig 或直连 Service 权限。 |
+80 -3
View File
@@ -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 {
+171 -3
View File
@@ -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]; }