feat: wire role-scoped device creds
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DeviceAuthEntry: Codable, Sendable {
|
||||||
|
public let token: String
|
||||||
|
public let role: String
|
||||||
|
public let scopes: [String]
|
||||||
|
public let updatedAtMs: Int
|
||||||
|
|
||||||
|
public init(token: String, role: String, scopes: [String], updatedAtMs: Int) {
|
||||||
|
self.token = token
|
||||||
|
self.role = role
|
||||||
|
self.scopes = scopes
|
||||||
|
self.updatedAtMs = updatedAtMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DeviceAuthStoreFile: Codable {
|
||||||
|
var version: Int
|
||||||
|
var deviceId: String
|
||||||
|
var tokens: [String: DeviceAuthEntry]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DeviceAuthStore {
|
||||||
|
private static let fileName = "device-auth.json"
|
||||||
|
|
||||||
|
public static func loadToken(deviceId: String, role: String) -> DeviceAuthEntry? {
|
||||||
|
guard let store = readStore(), store.deviceId == deviceId else { return nil }
|
||||||
|
let role = normalizeRole(role)
|
||||||
|
return store.tokens[role]
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func storeToken(
|
||||||
|
deviceId: String,
|
||||||
|
role: String,
|
||||||
|
token: String,
|
||||||
|
scopes: [String] = []
|
||||||
|
) -> DeviceAuthEntry {
|
||||||
|
let normalizedRole = normalizeRole(role)
|
||||||
|
var next = readStore()
|
||||||
|
if next?.deviceId != deviceId {
|
||||||
|
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||||
|
}
|
||||||
|
let entry = DeviceAuthEntry(
|
||||||
|
token: token,
|
||||||
|
role: normalizedRole,
|
||||||
|
scopes: normalizeScopes(scopes),
|
||||||
|
updatedAtMs: Int(Date().timeIntervalSince1970 * 1000)
|
||||||
|
)
|
||||||
|
if next == nil {
|
||||||
|
next = DeviceAuthStoreFile(version: 1, deviceId: deviceId, tokens: [:])
|
||||||
|
}
|
||||||
|
next?.tokens[normalizedRole] = entry
|
||||||
|
if let store = next {
|
||||||
|
writeStore(store)
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func clearToken(deviceId: String, role: String) {
|
||||||
|
guard var store = readStore(), store.deviceId == deviceId else { return }
|
||||||
|
let normalizedRole = normalizeRole(role)
|
||||||
|
guard store.tokens[normalizedRole] != nil else { return }
|
||||||
|
store.tokens.removeValue(forKey: normalizedRole)
|
||||||
|
writeStore(store)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeRole(_ role: String) -> String {
|
||||||
|
role.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizeScopes(_ scopes: [String]) -> [String] {
|
||||||
|
let trimmed = scopes
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
return Array(Set(trimmed)).sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fileURL() -> URL {
|
||||||
|
DeviceIdentityPaths.stateDirURL()
|
||||||
|
.appendingPathComponent("identity", isDirectory: true)
|
||||||
|
.appendingPathComponent(fileName, isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func readStore() -> DeviceAuthStoreFile? {
|
||||||
|
let url = fileURL()
|
||||||
|
guard let data = try? Data(contentsOf: url) else { return nil }
|
||||||
|
guard let decoded = try? JSONDecoder().decode(DeviceAuthStoreFile.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard decoded.version == 1 else { return nil }
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func writeStore(_ store: DeviceAuthStoreFile) {
|
||||||
|
let url = fileURL()
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: url.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true)
|
||||||
|
let data = try JSONEncoder().encode(store)
|
||||||
|
try data.write(to: url, options: [.atomic])
|
||||||
|
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||||
|
} catch {
|
||||||
|
// best-effort only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,6 +261,8 @@ public actor GatewayChannelActor {
|
|||||||
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
||||||
let clientId = options.clientId
|
let clientId = options.clientId
|
||||||
let clientMode = options.clientMode
|
let clientMode = options.clientMode
|
||||||
|
let role = options.role
|
||||||
|
let scopes = options.scopes
|
||||||
|
|
||||||
let reqId = UUID().uuidString
|
let reqId = UUID().uuidString
|
||||||
var client: [String: ProtoAnyCodable] = [
|
var client: [String: ProtoAnyCodable] = [
|
||||||
@@ -283,8 +285,8 @@ public actor GatewayChannelActor {
|
|||||||
"caps": ProtoAnyCodable(options.caps),
|
"caps": ProtoAnyCodable(options.caps),
|
||||||
"locale": ProtoAnyCodable(primaryLocale),
|
"locale": ProtoAnyCodable(primaryLocale),
|
||||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
"role": ProtoAnyCodable(options.role),
|
"role": ProtoAnyCodable(role),
|
||||||
"scopes": ProtoAnyCodable(options.scopes),
|
"scopes": ProtoAnyCodable(scopes),
|
||||||
]
|
]
|
||||||
if !options.commands.isEmpty {
|
if !options.commands.isEmpty {
|
||||||
params["commands"] = ProtoAnyCodable(options.commands)
|
params["commands"] = ProtoAnyCodable(options.commands)
|
||||||
@@ -292,24 +294,27 @@ public actor GatewayChannelActor {
|
|||||||
if !options.permissions.isEmpty {
|
if !options.permissions.isEmpty {
|
||||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||||
}
|
}
|
||||||
if let token = self.token {
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
let storedToken = DeviceAuthStore.loadToken(deviceId: identity.deviceId, role: role)?.token
|
||||||
|
let authToken = storedToken ?? self.token
|
||||||
|
let canFallbackToShared = storedToken != nil && self.token != nil
|
||||||
|
if let authToken {
|
||||||
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(authToken)])
|
||||||
} else if let password = self.password {
|
} else if let password = self.password {
|
||||||
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
params["auth"] = ProtoAnyCodable(["password": ProtoAnyCodable(password)])
|
||||||
}
|
}
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
let connectNonce = try await self.waitForConnectChallenge()
|
let connectNonce = try await self.waitForConnectChallenge()
|
||||||
let scopes = options.scopes.joined(separator: ",")
|
let scopesValue = scopes.joined(separator: ",")
|
||||||
var payloadParts = [
|
var payloadParts = [
|
||||||
connectNonce == nil ? "v1" : "v2",
|
connectNonce == nil ? "v1" : "v2",
|
||||||
identity.deviceId,
|
identity.deviceId,
|
||||||
clientId,
|
clientId,
|
||||||
clientMode,
|
clientMode,
|
||||||
options.role,
|
role,
|
||||||
scopes,
|
scopesValue,
|
||||||
String(signedAtMs),
|
String(signedAtMs),
|
||||||
self.token ?? "",
|
authToken ?? "",
|
||||||
]
|
]
|
||||||
if let connectNonce {
|
if let connectNonce {
|
||||||
payloadParts.append(connectNonce)
|
payloadParts.append(connectNonce)
|
||||||
@@ -336,11 +341,22 @@ public actor GatewayChannelActor {
|
|||||||
params: ProtoAnyCodable(params))
|
params: ProtoAnyCodable(params))
|
||||||
let data = try self.encoder.encode(frame)
|
let data = try self.encoder.encode(frame)
|
||||||
try await self.task?.send(.data(data))
|
try await self.task?.send(.data(data))
|
||||||
let response = try await self.waitForConnectResponse(reqId: reqId)
|
do {
|
||||||
try await self.handleConnectResponse(response)
|
let response = try await self.waitForConnectResponse(reqId: reqId)
|
||||||
|
try await self.handleConnectResponse(response, identity: identity, role: role)
|
||||||
|
} catch {
|
||||||
|
if canFallbackToShared {
|
||||||
|
DeviceAuthStore.clearToken(deviceId: identity.deviceId, role: role)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleConnectResponse(_ res: ResponseFrame) async throws {
|
private func handleConnectResponse(
|
||||||
|
_ res: ResponseFrame,
|
||||||
|
identity: DeviceIdentity,
|
||||||
|
role: String
|
||||||
|
) async throws {
|
||||||
if res.ok == false {
|
if res.ok == false {
|
||||||
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
let msg = (res.error?["message"]?.value as? String) ?? "gateway connect failed"
|
||||||
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
throw NSError(domain: "Gateway", code: 1008, userInfo: [NSLocalizedDescriptionKey: msg])
|
||||||
@@ -358,6 +374,17 @@ public actor GatewayChannelActor {
|
|||||||
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
} else if let tick = ok.policy["tickIntervalMs"]?.value as? Int {
|
||||||
self.tickIntervalMs = Double(tick)
|
self.tickIntervalMs = Double(tick)
|
||||||
}
|
}
|
||||||
|
if let auth = ok.auth,
|
||||||
|
let deviceToken = auth["deviceToken"]?.value as? String {
|
||||||
|
let authRole = auth["role"]?.value as? String ?? role
|
||||||
|
let scopes = (auth["scopes"]?.value as? [ProtoAnyCodable])?
|
||||||
|
.compactMap { $0.value as? String } ?? []
|
||||||
|
_ = DeviceAuthStore.storeToken(
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role: authRole,
|
||||||
|
token: deviceToken,
|
||||||
|
scopes: scopes)
|
||||||
|
}
|
||||||
self.lastTick = Date()
|
self.lastTick = Date()
|
||||||
self.tickTask?.cancel()
|
self.tickTask?.cancel()
|
||||||
self.tickTask = Task { [weak self] in
|
self.tickTask = Task { [weak self] in
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ Same `deviceId` across roles → single “Instance” row:
|
|||||||
|
|
||||||
# Execution checklist (ship order)
|
# Execution checklist (ship order)
|
||||||
- [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local.
|
- [x] **Device‑bound auth (PoP):** nonce challenge + signature verify on connect; remove bearer‑only for non‑local.
|
||||||
- [ ] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
|
- [x] **Role‑scoped creds:** issue per‑role tokens, rotate, revoke, list; UI/CLI surfaced; audit log entries.
|
||||||
- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests.
|
- [ ] **Scope enforcement:** keep paired scopes in sync on rotation; reject/upgrade flows explicit; tests.
|
||||||
- [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting.
|
- [ ] **Approvals routing:** gateway‑hosted approvals; operator UI prompt/resolve; node stops prompting.
|
||||||
- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation.
|
- [ ] **TLS pinning for WS:** reuse bridge TLS runtime; discovery advertises fingerprint; client validation.
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import {
|
|||||||
publicKeyRawBase64UrlFromPem,
|
publicKeyRawBase64UrlFromPem,
|
||||||
signDevicePayload,
|
signDevicePayload,
|
||||||
} from "../infra/device-identity.js";
|
} from "../infra/device-identity.js";
|
||||||
import { loadDeviceAuthToken, storeDeviceAuthToken } from "../infra/device-auth-store.js";
|
import {
|
||||||
|
clearDeviceAuthToken,
|
||||||
|
loadDeviceAuthToken,
|
||||||
|
storeDeviceAuthToken,
|
||||||
|
} from "../infra/device-auth-store.js";
|
||||||
import {
|
import {
|
||||||
GATEWAY_CLIENT_MODES,
|
GATEWAY_CLIENT_MODES,
|
||||||
GATEWAY_CLIENT_NAMES,
|
GATEWAY_CLIENT_NAMES,
|
||||||
@@ -160,7 +164,8 @@ export class GatewayClient {
|
|||||||
const storedToken = this.opts.deviceIdentity
|
const storedToken = this.opts.deviceIdentity
|
||||||
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
? loadDeviceAuthToken({ deviceId: this.opts.deviceIdentity.deviceId, role })?.token
|
||||||
: null;
|
: null;
|
||||||
const authToken = this.opts.token ?? storedToken ?? undefined;
|
const authToken = storedToken ?? this.opts.token ?? undefined;
|
||||||
|
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||||
const auth =
|
const auth =
|
||||||
authToken || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
@@ -236,6 +241,12 @@ export class GatewayClient {
|
|||||||
this.opts.onHelloOk?.(helloOk);
|
this.opts.onHelloOk?.(helloOk);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
if (canFallbackToShared && this.opts.deviceIdentity) {
|
||||||
|
clearDeviceAuthToken({
|
||||||
|
deviceId: this.opts.deviceIdentity.deviceId,
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
}
|
||||||
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
this.opts.onConnectError?.(err instanceof Error ? err : new Error(String(err)));
|
||||||
const msg = `gateway connect failed: ${String(err)}`;
|
const msg = `gateway connect failed: ${String(err)}`;
|
||||||
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg);
|
if (this.opts.mode === GATEWAY_CLIENT_MODES.PROBE) logDebug(msg);
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import {
|
|||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
function redactPairedDevice(device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>) {
|
function redactPairedDevice(
|
||||||
|
device: { tokens?: Record<string, DeviceAuthToken> } & Record<string, unknown>,
|
||||||
|
) {
|
||||||
const { tokens, ...rest } = device;
|
const { tokens, ...rest } = device;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
@@ -72,6 +74,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown requestId"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
context.logGateway.info(
|
||||||
|
`device pairing approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||||
|
);
|
||||||
context.broadcast(
|
context.broadcast(
|
||||||
"device.pair.resolved",
|
"device.pair.resolved",
|
||||||
{
|
{
|
||||||
@@ -79,7 +84,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
deviceId: approved.device.deviceId,
|
deviceId: approved.device.deviceId,
|
||||||
decision: "approved",
|
decision: "approved",
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
},
|
},
|
||||||
{ dropIfSlow: true },
|
{ dropIfSlow: true },
|
||||||
);
|
);
|
||||||
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
|
respond(true, { requestId, device: redactPairedDevice(approved.device) }, undefined);
|
||||||
@@ -116,7 +121,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
respond(true, rejected, undefined);
|
respond(true, rejected, undefined);
|
||||||
},
|
},
|
||||||
"device.token.rotate": async ({ params, respond }) => {
|
"device.token.rotate": async ({ params, respond, context }) => {
|
||||||
if (!validateDeviceTokenRotateParams(params)) {
|
if (!validateDeviceTokenRotateParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -140,6 +145,9 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
context.logGateway.info(
|
||||||
|
`device token rotated device=${deviceId} role=${entry.role} scopes=${entry.scopes.join(",")}`,
|
||||||
|
);
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{
|
{
|
||||||
@@ -152,7 +160,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
"device.token.revoke": async ({ params, respond }) => {
|
"device.token.revoke": async ({ params, respond, context }) => {
|
||||||
if (!validateDeviceTokenRevokeParams(params)) {
|
if (!validateDeviceTokenRevokeParams(params)) {
|
||||||
respond(
|
respond(
|
||||||
false,
|
false,
|
||||||
@@ -172,6 +180,7 @@ export const deviceHandlers: GatewayRequestHandlers = {
|
|||||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown deviceId/role"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
context.logGateway.info(`device token revoked device=${deviceId} role=${entry.role}`);
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
|
{ deviceId, role: entry.role, revokedAtMs: entry.revokedAtMs ?? Date.now() },
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import type { NodeRegistry } from "../node-registry.js";
|
|||||||
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
|
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
|
||||||
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
|
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
|
||||||
import type { DedupeEntry } from "../server-shared.js";
|
import type { DedupeEntry } from "../server-shared.js";
|
||||||
|
import type { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||||
|
|
||||||
|
type SubsystemLogger = ReturnType<typeof createSubsystemLogger>;
|
||||||
|
|
||||||
export type GatewayClient = {
|
export type GatewayClient = {
|
||||||
connect: ConnectParams;
|
connect: ConnectParams;
|
||||||
@@ -28,7 +31,7 @@ export type GatewayRequestContext = {
|
|||||||
getHealthCache: () => HealthSummary | null;
|
getHealthCache: () => HealthSummary | null;
|
||||||
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||||
logHealth: { error: (message: string) => void };
|
logHealth: { error: (message: string) => void };
|
||||||
logGateway: { warn: (message: string) => void };
|
logGateway: SubsystemLogger;
|
||||||
incrementPresenceVersion: () => number;
|
incrementPresenceVersion: () => number;
|
||||||
getHealthVersion: () => number;
|
getHealthVersion: () => number;
|
||||||
broadcast: (
|
broadcast: (
|
||||||
|
|||||||
@@ -487,6 +487,9 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
if (pairing.request.silent === true) {
|
if (pairing.request.silent === true) {
|
||||||
const approved = await approveDevicePairing(pairing.request.requestId);
|
const approved = await approveDevicePairing(pairing.request.requestId);
|
||||||
if (approved) {
|
if (approved) {
|
||||||
|
logGateway.info(
|
||||||
|
`device pairing auto-approved device=${approved.device.deviceId} role=${approved.device.role ?? "unknown"}`,
|
||||||
|
);
|
||||||
context.broadcast(
|
context.broadcast(
|
||||||
"device.pair.resolved",
|
"device.pair.resolved",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -102,3 +102,22 @@ export function storeDeviceAuthToken(params: {
|
|||||||
writeStore(filePath, next);
|
writeStore(filePath, next);
|
||||||
return entry;
|
return entry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function clearDeviceAuthToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
}): void {
|
||||||
|
const filePath = resolveDeviceAuthPath(params.env);
|
||||||
|
const store = readStore(filePath);
|
||||||
|
if (!store || store.deviceId !== params.deviceId) return;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!store.tokens[role]) return;
|
||||||
|
const next: DeviceAuthStore = {
|
||||||
|
version: 1,
|
||||||
|
deviceId: store.deviceId,
|
||||||
|
tokens: { ...store.tokens },
|
||||||
|
};
|
||||||
|
delete next.tokens[role];
|
||||||
|
writeStore(filePath, next);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { loadChatHistory } from "./controllers/chat";
|
import { loadChatHistory } from "./controllers/chat";
|
||||||
|
import { loadDevices } from "./controllers/devices";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
import type { GatewayEventFrame, GatewayHelloOk } from "./gateway";
|
||||||
import { GatewayBrowserClient } from "./gateway";
|
import { GatewayBrowserClient } from "./gateway";
|
||||||
@@ -106,6 +107,7 @@ export function connectGateway(host: GatewayHost) {
|
|||||||
host.hello = hello;
|
host.hello = hello;
|
||||||
applySnapshot(host, hello);
|
applySnapshot(host, hello);
|
||||||
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
|
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
void refreshActiveTab(host as unknown as Parameters<typeof refreshActiveTab>[0]);
|
||||||
},
|
},
|
||||||
onClose: ({ code, reason }) => {
|
onClose: ({ code, reason }) => {
|
||||||
@@ -169,6 +171,10 @@ export function handleGatewayEvent(host: GatewayHost, evt: GatewayEventFrame) {
|
|||||||
if (evt.event === "cron" && host.tab === "cron") {
|
if (evt.event === "cron" && host.tab === "cron") {
|
||||||
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
|
void loadCron(host as unknown as Parameters<typeof loadCron>[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (evt.event === "device.pair.requested" || evt.event === "device.pair.resolved") {
|
||||||
|
void loadDevices(host as unknown as ClawdbotApp, { quiet: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
export function applySnapshot(host: GatewayHost, hello: GatewayHelloOk) {
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ import { renderLogs } from "./views/logs";
|
|||||||
import { renderNodes } from "./views/nodes";
|
import { renderNodes } from "./views/nodes";
|
||||||
import { renderOverview } from "./views/overview";
|
import { renderOverview } from "./views/overview";
|
||||||
import { renderSessions } from "./views/sessions";
|
import { renderSessions } from "./views/sessions";
|
||||||
|
import {
|
||||||
|
approveDevicePairing,
|
||||||
|
loadDevices,
|
||||||
|
rejectDevicePairing,
|
||||||
|
revokeDeviceToken,
|
||||||
|
rotateDeviceToken,
|
||||||
|
} from "./controllers/devices";
|
||||||
import { renderSkills } from "./views/skills";
|
import { renderSkills } from "./views/skills";
|
||||||
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
import { renderChatControls, renderTab, renderThemeToggle } from "./app-render.helpers";
|
||||||
import { loadChannels } from "./controllers/channels";
|
import { loadChannels } from "./controllers/channels";
|
||||||
@@ -301,6 +308,9 @@ export function renderApp(state: AppViewState) {
|
|||||||
? renderNodes({
|
? renderNodes({
|
||||||
loading: state.nodesLoading,
|
loading: state.nodesLoading,
|
||||||
nodes: state.nodes,
|
nodes: state.nodes,
|
||||||
|
devicesLoading: state.devicesLoading,
|
||||||
|
devicesError: state.devicesError,
|
||||||
|
devicesList: state.devicesList,
|
||||||
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
configForm: state.configForm ?? (state.configSnapshot?.config as Record<string, unknown> | null),
|
||||||
configLoading: state.configLoading,
|
configLoading: state.configLoading,
|
||||||
configSaving: state.configSaving,
|
configSaving: state.configSaving,
|
||||||
@@ -315,6 +325,13 @@ export function renderApp(state: AppViewState) {
|
|||||||
execApprovalsTarget: state.execApprovalsTarget,
|
execApprovalsTarget: state.execApprovalsTarget,
|
||||||
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
execApprovalsTargetNodeId: state.execApprovalsTargetNodeId,
|
||||||
onRefresh: () => loadNodes(state),
|
onRefresh: () => loadNodes(state),
|
||||||
|
onDevicesRefresh: () => loadDevices(state),
|
||||||
|
onDeviceApprove: (requestId) => approveDevicePairing(state, requestId),
|
||||||
|
onDeviceReject: (requestId) => rejectDevicePairing(state, requestId),
|
||||||
|
onDeviceRotate: (deviceId, role, scopes) =>
|
||||||
|
rotateDeviceToken(state, { deviceId, role, scopes }),
|
||||||
|
onDeviceRevoke: (deviceId, role) =>
|
||||||
|
revokeDeviceToken(state, { deviceId, role }),
|
||||||
onLoadConfig: () => loadConfig(state),
|
onLoadConfig: () => loadConfig(state),
|
||||||
onLoadExecApprovals: () => {
|
onLoadExecApprovals: () => {
|
||||||
const target =
|
const target =
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { loadCronJobs, loadCronStatus } from "./controllers/cron";
|
|||||||
import { loadChannels } from "./controllers/channels";
|
import { loadChannels } from "./controllers/channels";
|
||||||
import { loadDebug } from "./controllers/debug";
|
import { loadDebug } from "./controllers/debug";
|
||||||
import { loadLogs } from "./controllers/logs";
|
import { loadLogs } from "./controllers/logs";
|
||||||
|
import { loadDevices } from "./controllers/devices";
|
||||||
import { loadNodes } from "./controllers/nodes";
|
import { loadNodes } from "./controllers/nodes";
|
||||||
import { loadExecApprovals } from "./controllers/exec-approvals";
|
import { loadExecApprovals } from "./controllers/exec-approvals";
|
||||||
import { loadPresence } from "./controllers/presence";
|
import { loadPresence } from "./controllers/presence";
|
||||||
@@ -136,6 +137,7 @@ export async function refreshActiveTab(host: SettingsHost) {
|
|||||||
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
|
if (host.tab === "skills") await loadSkills(host as unknown as ClawdbotApp);
|
||||||
if (host.tab === "nodes") {
|
if (host.tab === "nodes") {
|
||||||
await loadNodes(host as unknown as ClawdbotApp);
|
await loadNodes(host as unknown as ClawdbotApp);
|
||||||
|
await loadDevices(host as unknown as ClawdbotApp);
|
||||||
await loadConfig(host as unknown as ClawdbotApp);
|
await loadConfig(host as unknown as ClawdbotApp);
|
||||||
await loadExecApprovals(host as unknown as ClawdbotApp);
|
await loadExecApprovals(host as unknown as ClawdbotApp);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type {
|
|||||||
ExecApprovalsFile,
|
ExecApprovalsFile,
|
||||||
ExecApprovalsSnapshot,
|
ExecApprovalsSnapshot,
|
||||||
} from "./controllers/exec-approvals";
|
} from "./controllers/exec-approvals";
|
||||||
|
import type { DevicePairingList } from "./controllers/devices";
|
||||||
|
|
||||||
export type AppViewState = {
|
export type AppViewState = {
|
||||||
settings: UiSettings;
|
settings: UiSettings;
|
||||||
@@ -48,6 +49,9 @@ export type AppViewState = {
|
|||||||
chatQueue: ChatQueueItem[];
|
chatQueue: ChatQueueItem[];
|
||||||
nodesLoading: boolean;
|
nodesLoading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
|
devicesLoading: boolean;
|
||||||
|
devicesError: string | null;
|
||||||
|
devicesList: DevicePairingList | null;
|
||||||
execApprovalsLoading: boolean;
|
execApprovalsLoading: boolean;
|
||||||
execApprovalsSaving: boolean;
|
execApprovalsSaving: boolean;
|
||||||
execApprovalsDirty: boolean;
|
execApprovalsDirty: boolean;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import type {
|
|||||||
ExecApprovalsFile,
|
ExecApprovalsFile,
|
||||||
ExecApprovalsSnapshot,
|
ExecApprovalsSnapshot,
|
||||||
} from "./controllers/exec-approvals";
|
} from "./controllers/exec-approvals";
|
||||||
|
import type { DevicePairingList } from "./controllers/devices";
|
||||||
import {
|
import {
|
||||||
resetToolStream as resetToolStreamInternal,
|
resetToolStream as resetToolStreamInternal,
|
||||||
toggleToolOutput as toggleToolOutputInternal,
|
toggleToolOutput as toggleToolOutputInternal,
|
||||||
@@ -108,6 +109,9 @@ export class ClawdbotApp extends LitElement {
|
|||||||
|
|
||||||
@state() nodesLoading = false;
|
@state() nodesLoading = false;
|
||||||
@state() nodes: Array<Record<string, unknown>> = [];
|
@state() nodes: Array<Record<string, unknown>> = [];
|
||||||
|
@state() devicesLoading = false;
|
||||||
|
@state() devicesError: string | null = null;
|
||||||
|
@state() devicesList: DevicePairingList | null = null;
|
||||||
@state() execApprovalsLoading = false;
|
@state() execApprovalsLoading = false;
|
||||||
@state() execApprovalsSaving = false;
|
@state() execApprovalsSaving = false;
|
||||||
@state() execApprovalsDirty = false;
|
@state() execApprovalsDirty = false;
|
||||||
|
|||||||
135
ui/src/ui/controllers/devices.ts
Normal file
135
ui/src/ui/controllers/devices.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import type { GatewayBrowserClient } from "../gateway";
|
||||||
|
import { loadOrCreateDeviceIdentity } from "../device-identity";
|
||||||
|
import { clearDeviceAuthToken, storeDeviceAuthToken } from "../device-auth";
|
||||||
|
|
||||||
|
export type DeviceTokenSummary = {
|
||||||
|
role: string;
|
||||||
|
scopes?: string[];
|
||||||
|
createdAtMs?: number;
|
||||||
|
rotatedAtMs?: number;
|
||||||
|
revokedAtMs?: number;
|
||||||
|
lastUsedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PendingDevice = {
|
||||||
|
requestId: string;
|
||||||
|
deviceId: string;
|
||||||
|
displayName?: string;
|
||||||
|
role?: string;
|
||||||
|
remoteIp?: string;
|
||||||
|
isRepair?: boolean;
|
||||||
|
ts?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PairedDevice = {
|
||||||
|
deviceId: string;
|
||||||
|
displayName?: string;
|
||||||
|
roles?: string[];
|
||||||
|
scopes?: string[];
|
||||||
|
remoteIp?: string;
|
||||||
|
tokens?: DeviceTokenSummary[];
|
||||||
|
createdAtMs?: number;
|
||||||
|
approvedAtMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevicePairingList = {
|
||||||
|
pending: PendingDevice[];
|
||||||
|
paired: PairedDevice[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DevicesState = {
|
||||||
|
client: GatewayBrowserClient | null;
|
||||||
|
connected: boolean;
|
||||||
|
devicesLoading: boolean;
|
||||||
|
devicesError: string | null;
|
||||||
|
devicesList: DevicePairingList | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loadDevices(state: DevicesState, opts?: { quiet?: boolean }) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
if (state.devicesLoading) return;
|
||||||
|
state.devicesLoading = true;
|
||||||
|
if (!opts?.quiet) state.devicesError = null;
|
||||||
|
try {
|
||||||
|
const res = (await state.client.request("device.pair.list", {})) as DevicePairingList | null;
|
||||||
|
state.devicesList = {
|
||||||
|
pending: Array.isArray(res?.pending) ? res!.pending : [],
|
||||||
|
paired: Array.isArray(res?.paired) ? res!.paired : [],
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (!opts?.quiet) state.devicesError = String(err);
|
||||||
|
} finally {
|
||||||
|
state.devicesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approveDevicePairing(state: DevicesState, requestId: string) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
try {
|
||||||
|
await state.client.request("device.pair.approve", { requestId });
|
||||||
|
await loadDevices(state);
|
||||||
|
} catch (err) {
|
||||||
|
state.devicesError = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rejectDevicePairing(state: DevicesState, requestId: string) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
const confirmed = window.confirm("Reject this device pairing request?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await state.client.request("device.pair.reject", { requestId });
|
||||||
|
await loadDevices(state);
|
||||||
|
} catch (err) {
|
||||||
|
state.devicesError = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rotateDeviceToken(
|
||||||
|
state: DevicesState,
|
||||||
|
params: { deviceId: string; role: string; scopes?: string[] },
|
||||||
|
) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
try {
|
||||||
|
const res = (await state.client.request("device.token.rotate", params)) as
|
||||||
|
| { token?: string; role?: string; deviceId?: string; scopes?: string[] }
|
||||||
|
| undefined;
|
||||||
|
if (res?.token) {
|
||||||
|
const identity = await loadOrCreateDeviceIdentity();
|
||||||
|
const role = res.role ?? params.role;
|
||||||
|
if (res.deviceId === identity.deviceId || params.deviceId === identity.deviceId) {
|
||||||
|
storeDeviceAuthToken({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
role,
|
||||||
|
token: res.token,
|
||||||
|
scopes: res.scopes ?? params.scopes ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.prompt("New device token (copy and store securely):", res.token);
|
||||||
|
}
|
||||||
|
await loadDevices(state);
|
||||||
|
} catch (err) {
|
||||||
|
state.devicesError = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeDeviceToken(
|
||||||
|
state: DevicesState,
|
||||||
|
params: { deviceId: string; role: string },
|
||||||
|
) {
|
||||||
|
if (!state.client || !state.connected) return;
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
`Revoke token for ${params.deviceId} (${params.role})?`,
|
||||||
|
);
|
||||||
|
if (!confirmed) return;
|
||||||
|
try {
|
||||||
|
await state.client.request("device.token.revoke", params);
|
||||||
|
const identity = await loadOrCreateDeviceIdentity();
|
||||||
|
if (params.deviceId === identity.deviceId) {
|
||||||
|
clearDeviceAuthToken({ deviceId: identity.deviceId, role: params.role });
|
||||||
|
}
|
||||||
|
await loadDevices(state);
|
||||||
|
} catch (err) {
|
||||||
|
state.devicesError = String(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
99
ui/src/ui/device-auth.ts
Normal file
99
ui/src/ui/device-auth.ts
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
export type DeviceAuthEntry = {
|
||||||
|
token: string;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
updatedAtMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeviceAuthStore = {
|
||||||
|
version: 1;
|
||||||
|
deviceId: string;
|
||||||
|
tokens: Record<string, DeviceAuthEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STORAGE_KEY = "clawdbot.device.auth.v1";
|
||||||
|
|
||||||
|
function normalizeRole(role: string): string {
|
||||||
|
return role.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeScopes(scopes: string[] | undefined): string[] {
|
||||||
|
if (!Array.isArray(scopes)) return [];
|
||||||
|
const out = new Set<string>();
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const trimmed = scope.trim();
|
||||||
|
if (trimmed) out.add(trimmed);
|
||||||
|
}
|
||||||
|
return [...out].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore(): DeviceAuthStore | null {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return null;
|
||||||
|
const parsed = JSON.parse(raw) as DeviceAuthStore;
|
||||||
|
if (!parsed || parsed.version !== 1) return null;
|
||||||
|
if (!parsed.deviceId || typeof parsed.deviceId !== "string") return null;
|
||||||
|
if (!parsed.tokens || typeof parsed.tokens !== "object") return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(store: DeviceAuthStore) {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadDeviceAuthToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
}): DeviceAuthEntry | null {
|
||||||
|
const store = readStore();
|
||||||
|
if (!store || store.deviceId !== params.deviceId) return null;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
const entry = store.tokens[role];
|
||||||
|
if (!entry || typeof entry.token !== "string") return null;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function storeDeviceAuthToken(params: {
|
||||||
|
deviceId: string;
|
||||||
|
role: string;
|
||||||
|
token: string;
|
||||||
|
scopes?: string[];
|
||||||
|
}): DeviceAuthEntry {
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
const next: DeviceAuthStore = {
|
||||||
|
version: 1,
|
||||||
|
deviceId: params.deviceId,
|
||||||
|
tokens: {},
|
||||||
|
};
|
||||||
|
const existing = readStore();
|
||||||
|
if (existing && existing.deviceId === params.deviceId) {
|
||||||
|
next.tokens = { ...existing.tokens };
|
||||||
|
}
|
||||||
|
const entry: DeviceAuthEntry = {
|
||||||
|
token: params.token,
|
||||||
|
role,
|
||||||
|
scopes: normalizeScopes(params.scopes),
|
||||||
|
updatedAtMs: Date.now(),
|
||||||
|
};
|
||||||
|
next.tokens[role] = entry;
|
||||||
|
writeStore(next);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDeviceAuthToken(params: { deviceId: string; role: string }) {
|
||||||
|
const store = readStore();
|
||||||
|
if (!store || store.deviceId !== params.deviceId) return;
|
||||||
|
const role = normalizeRole(params.role);
|
||||||
|
if (!store.tokens[role]) return;
|
||||||
|
const next = { ...store, tokens: { ...store.tokens } };
|
||||||
|
delete next.tokens[role];
|
||||||
|
writeStore(next);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from "../../../src/gateway/protocol/client-info.js";
|
} from "../../../src/gateway/protocol/client-info.js";
|
||||||
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
|
import { buildDeviceAuthPayload } from "../../../src/gateway/device-auth.js";
|
||||||
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
|
import { loadOrCreateDeviceIdentity, signDevicePayload } from "./device-identity";
|
||||||
|
import { clearDeviceAuthToken, loadDeviceAuthToken, storeDeviceAuthToken } from "./device-auth";
|
||||||
|
|
||||||
export type GatewayEventFrame = {
|
export type GatewayEventFrame = {
|
||||||
type: "event";
|
type: "event";
|
||||||
@@ -29,6 +30,12 @@ export type GatewayHelloOk = {
|
|||||||
protocol: number;
|
protocol: number;
|
||||||
features?: { methods?: string[]; events?: string[] };
|
features?: { methods?: string[]; events?: string[] };
|
||||||
snapshot?: unknown;
|
snapshot?: unknown;
|
||||||
|
auth?: {
|
||||||
|
deviceToken?: string;
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
issuedAtMs?: number;
|
||||||
|
};
|
||||||
policy?: { tickIntervalMs?: number };
|
policy?: { tickIntervalMs?: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,15 +127,21 @@ export class GatewayBrowserClient {
|
|||||||
this.connectTimer = null;
|
this.connectTimer = null;
|
||||||
}
|
}
|
||||||
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
const deviceIdentity = await loadOrCreateDeviceIdentity();
|
||||||
|
const scopes = ["operator.admin"];
|
||||||
|
const role = "operator";
|
||||||
|
const storedToken = loadDeviceAuthToken({
|
||||||
|
deviceId: deviceIdentity.deviceId,
|
||||||
|
role,
|
||||||
|
})?.token;
|
||||||
|
const authToken = storedToken ?? this.opts.token;
|
||||||
|
const canFallbackToShared = Boolean(storedToken && this.opts.token);
|
||||||
const auth =
|
const auth =
|
||||||
this.opts.token || this.opts.password
|
authToken || this.opts.password
|
||||||
? {
|
? {
|
||||||
token: this.opts.token,
|
token: authToken,
|
||||||
password: this.opts.password,
|
password: this.opts.password,
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
const scopes = ["operator.admin"];
|
|
||||||
const role = "operator";
|
|
||||||
const signedAtMs = Date.now();
|
const signedAtMs = Date.now();
|
||||||
const nonce = this.connectNonce ?? undefined;
|
const nonce = this.connectNonce ?? undefined;
|
||||||
const payload = buildDeviceAuthPayload({
|
const payload = buildDeviceAuthPayload({
|
||||||
@@ -138,7 +151,7 @@ export class GatewayBrowserClient {
|
|||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: this.opts.token ?? null,
|
token: authToken ?? null,
|
||||||
nonce,
|
nonce,
|
||||||
});
|
});
|
||||||
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
const signature = await signDevicePayload(deviceIdentity.privateKey, payload);
|
||||||
@@ -169,10 +182,21 @@ export class GatewayBrowserClient {
|
|||||||
|
|
||||||
void this.request<GatewayHelloOk>("connect", params)
|
void this.request<GatewayHelloOk>("connect", params)
|
||||||
.then((hello) => {
|
.then((hello) => {
|
||||||
|
if (hello?.auth?.deviceToken) {
|
||||||
|
storeDeviceAuthToken({
|
||||||
|
deviceId: deviceIdentity.deviceId,
|
||||||
|
role: hello.auth.role ?? role,
|
||||||
|
token: hello.auth.deviceToken,
|
||||||
|
scopes: hello.auth.scopes ?? [],
|
||||||
|
});
|
||||||
|
}
|
||||||
this.backoffMs = 800;
|
this.backoffMs = 800;
|
||||||
this.opts.onHello?.(hello);
|
this.opts.onHello?.(hello);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
|
if (canFallbackToShared) {
|
||||||
|
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role });
|
||||||
|
}
|
||||||
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
this.ws?.close(CONNECT_FAILED_CLOSE_CODE, "connect failed");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
|
|
||||||
import { clampText, formatAgo } from "../format";
|
import { clampText, formatAgo, formatList } from "../format";
|
||||||
import type {
|
import type {
|
||||||
ExecApprovalsAllowlistEntry,
|
ExecApprovalsAllowlistEntry,
|
||||||
ExecApprovalsFile,
|
ExecApprovalsFile,
|
||||||
ExecApprovalsSnapshot,
|
ExecApprovalsSnapshot,
|
||||||
} from "../controllers/exec-approvals";
|
} from "../controllers/exec-approvals";
|
||||||
|
import type {
|
||||||
|
DevicePairingList,
|
||||||
|
DeviceTokenSummary,
|
||||||
|
PairedDevice,
|
||||||
|
PendingDevice,
|
||||||
|
} from "../controllers/devices";
|
||||||
|
|
||||||
export type NodesProps = {
|
export type NodesProps = {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
nodes: Array<Record<string, unknown>>;
|
nodes: Array<Record<string, unknown>>;
|
||||||
|
devicesLoading: boolean;
|
||||||
|
devicesError: string | null;
|
||||||
|
devicesList: DevicePairingList | null;
|
||||||
configForm: Record<string, unknown> | null;
|
configForm: Record<string, unknown> | null;
|
||||||
configLoading: boolean;
|
configLoading: boolean;
|
||||||
configSaving: boolean;
|
configSaving: boolean;
|
||||||
@@ -24,6 +33,11 @@ export type NodesProps = {
|
|||||||
execApprovalsTarget: "gateway" | "node";
|
execApprovalsTarget: "gateway" | "node";
|
||||||
execApprovalsTargetNodeId: string | null;
|
execApprovalsTargetNodeId: string | null;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onDevicesRefresh: () => void;
|
||||||
|
onDeviceApprove: (requestId: string) => void;
|
||||||
|
onDeviceReject: (requestId: string) => void;
|
||||||
|
onDeviceRotate: (deviceId: string, role: string, scopes?: string[]) => void;
|
||||||
|
onDeviceRevoke: (deviceId: string, role: string) => void;
|
||||||
onLoadConfig: () => void;
|
onLoadConfig: () => void;
|
||||||
onLoadExecApprovals: () => void;
|
onLoadExecApprovals: () => void;
|
||||||
onBindDefault: (nodeId: string | null) => void;
|
onBindDefault: (nodeId: string | null) => void;
|
||||||
@@ -42,6 +56,7 @@ export function renderNodes(props: NodesProps) {
|
|||||||
return html`
|
return html`
|
||||||
${renderExecApprovals(approvalsState)}
|
${renderExecApprovals(approvalsState)}
|
||||||
${renderBindings(bindingState)}
|
${renderBindings(bindingState)}
|
||||||
|
${renderDevices(props)}
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<div class="row" style="justify-content: space-between;">
|
<div class="row" style="justify-content: space-between;">
|
||||||
<div>
|
<div>
|
||||||
@@ -61,6 +76,128 @@ export function renderNodes(props: NodesProps) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderDevices(props: NodesProps) {
|
||||||
|
const list = props.devicesList ?? { pending: [], paired: [] };
|
||||||
|
const pending = Array.isArray(list.pending) ? list.pending : [];
|
||||||
|
const paired = Array.isArray(list.paired) ? list.paired : [];
|
||||||
|
return html`
|
||||||
|
<section class="card">
|
||||||
|
<div class="row" style="justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<div class="card-title">Devices</div>
|
||||||
|
<div class="card-sub">Pairing requests + role tokens.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn" ?disabled=${props.devicesLoading} @click=${props.onDevicesRefresh}>
|
||||||
|
${props.devicesLoading ? "Loading…" : "Refresh"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${props.devicesError
|
||||||
|
? html`<div class="callout danger" style="margin-top: 12px;">${props.devicesError}</div>`
|
||||||
|
: nothing}
|
||||||
|
<div class="list" style="margin-top: 16px;">
|
||||||
|
${pending.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="muted" style="margin-bottom: 8px;">Pending</div>
|
||||||
|
${pending.map((req) => renderPendingDevice(req, props))}
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${paired.length > 0
|
||||||
|
? html`
|
||||||
|
<div class="muted" style="margin-top: 12px; margin-bottom: 8px;">Paired</div>
|
||||||
|
${paired.map((device) => renderPairedDevice(device, props))}
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
${pending.length === 0 && paired.length === 0
|
||||||
|
? html`<div class="muted">No paired devices.</div>`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPendingDevice(req: PendingDevice, props: NodesProps) {
|
||||||
|
const name = req.displayName?.trim() || req.deviceId;
|
||||||
|
const age = typeof req.ts === "number" ? formatAgo(req.ts) : "n/a";
|
||||||
|
const role = req.role?.trim() ? `role: ${req.role}` : "role: -";
|
||||||
|
const repair = req.isRepair ? " · repair" : "";
|
||||||
|
const ip = req.remoteIp ? ` · ${req.remoteIp}` : "";
|
||||||
|
return html`
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">${name}</div>
|
||||||
|
<div class="list-sub">${req.deviceId}${ip}</div>
|
||||||
|
<div class="muted" style="margin-top: 6px;">
|
||||||
|
${role} · requested ${age}${repair}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-meta">
|
||||||
|
<div class="row" style="justify-content: flex-end; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button class="btn btn--sm primary" @click=${() => props.onDeviceApprove(req.requestId)}>
|
||||||
|
Approve
|
||||||
|
</button>
|
||||||
|
<button class="btn btn--sm" @click=${() => props.onDeviceReject(req.requestId)}>
|
||||||
|
Reject
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPairedDevice(device: PairedDevice, props: NodesProps) {
|
||||||
|
const name = device.displayName?.trim() || device.deviceId;
|
||||||
|
const ip = device.remoteIp ? ` · ${device.remoteIp}` : "";
|
||||||
|
const roles = `roles: ${formatList(device.roles)}`;
|
||||||
|
const scopes = `scopes: ${formatList(device.scopes)}`;
|
||||||
|
const tokens = Array.isArray(device.tokens) ? device.tokens : [];
|
||||||
|
return html`
|
||||||
|
<div class="list-item">
|
||||||
|
<div class="list-main">
|
||||||
|
<div class="list-title">${name}</div>
|
||||||
|
<div class="list-sub">${device.deviceId}${ip}</div>
|
||||||
|
<div class="muted" style="margin-top: 6px;">${roles} · ${scopes}</div>
|
||||||
|
${tokens.length === 0
|
||||||
|
? html`<div class="muted" style="margin-top: 6px;">Tokens: none</div>`
|
||||||
|
: html`
|
||||||
|
<div class="muted" style="margin-top: 10px;">Tokens</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-top: 6px;">
|
||||||
|
${tokens.map((token) => renderTokenRow(device.deviceId, token, props))}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTokenRow(deviceId: string, token: DeviceTokenSummary, props: NodesProps) {
|
||||||
|
const status = token.revokedAtMs ? "revoked" : "active";
|
||||||
|
const scopes = `scopes: ${formatList(token.scopes)}`;
|
||||||
|
const when = formatAgo(token.rotatedAtMs ?? token.createdAtMs ?? token.lastUsedAtMs ?? null);
|
||||||
|
return html`
|
||||||
|
<div class="row" style="justify-content: space-between; gap: 8px;">
|
||||||
|
<div class="list-sub">${token.role} · ${status} · ${scopes} · ${when}</div>
|
||||||
|
<div class="row" style="justify-content: flex-end; gap: 6px; flex-wrap: wrap;">
|
||||||
|
<button
|
||||||
|
class="btn btn--sm"
|
||||||
|
@click=${() => props.onDeviceRotate(deviceId, token.role, token.scopes)}
|
||||||
|
>
|
||||||
|
Rotate
|
||||||
|
</button>
|
||||||
|
${token.revokedAtMs
|
||||||
|
? nothing
|
||||||
|
: html`
|
||||||
|
<button
|
||||||
|
class="btn btn--sm danger"
|
||||||
|
@click=${() => props.onDeviceRevoke(deviceId, token.role)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
type BindingAgent = {
|
type BindingAgent = {
|
||||||
id: string;
|
id: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user