Merge pull request #549 from pikasTech/fix/v02-access-rest-status-20260529

fix: expose v02 access status routes
This commit is contained in:
Lyon
2026-05-29 08:55:36 +08:00
committed by GitHub
5 changed files with 131 additions and 9 deletions
+2 -1
View File
@@ -79,7 +79,7 @@ CREATE TABLE IF NOT EXISTS user_sessions (
);
```
- `/auth/session``/auth/login``/auth/logout` 的最终 authority 是 cloud-apicloud-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。 |
@@ -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。 |
+38
View File
@@ -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",
+71 -7
View File
@@ -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; }
+19 -1
View File
@@ -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"
},