Merge pull request #549 from pikasTech/fix/v02-access-rest-status-20260529
fix: expose v02 access status routes
This commit is contained in:
@@ -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。 |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user