diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 0d9b047d..653758d2 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS user_sessions ( ); ``` -- `/auth/session`、`/auth/login`、`/auth/logout` 的最终 authority 是 cloud-api;cloud-web 可以保留同名浏览器路由,但只能作为静态 UI 或代理层。 +- `/auth/session`、`/auth/login`、`/auth/logout` 的最终 authority 是 cloud-api;`/v1/auth/session`、`/v1/users/me`、`/v1/access/status` 和 `/v1/setup/status` 只作为同一 authority 的 REST 状态或兼容入口。cloud-web 可以保留同名浏览器路由,但只能作为静态 UI 或代理层。 - logout 是设置 `revoked_at`,不是仅删除浏览器本地状态。 ### `agent_sessions` @@ -271,6 +271,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device | --- | --- | | `POST /auth/login` | 校验本地账号并写入 `user_sessions` token hash。 | | `GET /auth/session` | 从 cookie 恢复 actor、role 和 session 状态。 | +| `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | REST 状态和兼容入口;不得读取或返回 password hash、session token 原文或 Secret 值。 | | `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 | | `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 | | `POST /v1/admin/device-pods`、`PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 | diff --git a/docs/reference/spec-v02-hwlab-cloud-api.md b/docs/reference/spec-v02-hwlab-cloud-api.md index e8e098ea..cea9377d 100644 --- a/docs/reference/spec-v02-hwlab-cloud-api.md +++ b/docs/reference/spec-v02-hwlab-cloud-api.md @@ -29,6 +29,7 @@ | `POST /v1/rpc/{method}` | REST 到 JSON-RPC 的桥接入口。 | | `GET /v1/device-pods...` | 经 cloud-api 鉴权后读取服务端 profile/grant/job authority;不会回退到 fake device pod 数据。 | | `GET /auth/session`、`POST /auth/login`、`POST /auth/logout` | v0.2 本地用户 session 入口。 | +| `GET /v1/auth/session`、`GET /v1/users/me`、`GET /v1/access/status`、`GET /v1/setup/status` | v0.2 用户/session/setup 的 REST 状态和兼容入口;响应不得暴露 password hash、session token 原文或 Secret 值。 | | `POST /v1/admin/users`、`POST/PUT /v1/admin/device-pods`、`POST/DELETE /v1/admin/device-pod-grants...` | `admin` 管理用户、device pod profile 和 grant 的入口。 | | `GET /v1/m3/status`、`POST /v1/m3/io` | M3 只读/受控 IO 入口;写操作必须有明确 approval。 | | `GET /v1/diagnostics/gate`、`GET /v1/live-builds` | 诊断和 live build inventory。 | diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 5f9415ad..d53e7880 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -127,6 +127,44 @@ test("cloud api protects device-pod routes when access control is required", asy } }); +test("cloud api exposes v1 access status routes and returns structured REST errors", async () => { + const server = createCloudApiServer({ + env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, + now: () => "2026-05-28T00:00:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const session = await getJson(port, "/v1/auth/session"); + assert.equal(session.status, 200); + assert.equal(session.body.authenticated, false); + assert.equal(session.body.setupRequired, true); + assert.equal(session.body.contractVersion, "user-access-v1"); + + const access = await getJson(port, "/v1/access/status"); + assert.equal(access.status, 200); + assert.equal(access.body.accessControlRequired, true); + assert.equal(access.body.routes.currentUser, "/v1/users/me"); + + const setup = await getJson(port, "/v1/setup/status"); + assert.equal(setup.status, 200); + assert.equal(setup.body.setupRequired, true); + assert.equal(setup.body.bootstrapAdminConfigured, false); + + const me = await getJson(port, "/v1/users/me"); + assert.equal(me.status, 401); + assert.equal(me.body.error.code, "auth_required"); + + const missing = await getJson(port, "/v1/not-implemented"); + assert.equal(missing.status, 404); + assert.equal(missing.body.error.code, "not_found"); + assert.equal(missing.body.error.audit.source.serviceId, "hwlab-cloud-api"); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + async function postJson(port, path, body, cookie = null) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { method: "POST", diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index e0671db7..e6f78c9c 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -142,13 +142,7 @@ class AccessController { try { await this.ensureBootstrap(); if (request.method === "GET" && url.pathname === "/auth/session") { - const auth = await this.authenticate(request, { required: false }); - sendJson(response, 200, { - authenticated: Boolean(auth.actor), - actor: auth.actor ? publicActor(auth.actor) : null, - setupRequired: await this.setupRequired(), - contractVersion: "user-access-v1" - }); + sendJson(response, 200, await this.sessionPayload(request)); return; } @@ -168,6 +162,75 @@ class AccessController { } } + 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, + contractVersion: "user-access-v1" + }); + } + + sendJson(response, 404, errorPayload("not_found", "User 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 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, + 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(); @@ -704,6 +767,7 @@ function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, lease, 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", devicePods: "/v1/device-pods" }; } function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } 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; } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 30d19b49..b15f3203 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -289,6 +289,23 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/auth/session" || url.pathname === "/v1/auth/login" || url.pathname === "/v1/auth/logout") { + const authUrl = new URL(url.href); + authUrl.pathname = url.pathname.replace(/^\/v1\/auth/u, "/auth"); + await options.accessController.handleAuthRoute(request, response, authUrl); + return; + } + + if (url.pathname === "/v1/users/me") { + await options.accessController.handleUserRoute(request, response, url); + return; + } + + if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") { + await options.accessController.handleAccessStatusRoute(request, response, url); + return; + } + if (url.pathname.startsWith("/v1/admin/")) { await options.accessController.handleAdminRoute(request, response, url); return; @@ -584,6 +601,7 @@ async function persistGatewayDemoSession({ runtimeStore, session, request, env = function sendRestError(request, response, statusCode, code, message, context = {}) { const requestId = getHeader(request, "x-request-id") || "req_unassigned"; + const environment = context.environment || runtimeEnvironment(context.env ?? process.env); sendJson(response, statusCode, { error: { code, @@ -597,7 +615,7 @@ function sendRestError(request, response, statusCode, code, message, context = { }, source: { serviceId: CLOUD_API_SERVICE_ID, - environment: runtimeEnvironment(options.env ?? process.env), + environment, traceId: getHeader(request, "x-trace-id"), adapter: "rest" },