diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 8d0fc6b2..4c9ff116 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -193,7 +193,8 @@ class AccessController { return; } if (request.method === "GET" && url.pathname === "/auth/session") { - sendJson(response, 200, await this.sessionPayload(request)); + const payload = await this.sessionPayload(request); + sendJson(response, payload.authenticated ? 200 : 401, payload); return; } @@ -360,16 +361,17 @@ class AccessController { 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, - authMethod: auth.actor ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null, - sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null, - sessionTtlSeconds: SESSION_MAX_AGE_SECONDS, - setupRequired: await this.setupRequired(), - contractVersion: "user-access-v1" - }; + const principal = canonicalPrincipalFromAuth(auth); + const setupRequired = await this.setupRequired(); + if (!principal.authenticated) { + return { + ...principal, + setupRequired, + contractVersion: "canonical-principal-v1", + error: { code: "auth_required", message: "Authentication is required", status: 401 } + }; + } + return sessionResponseFromAuth(auth, { setupRequired }); } async accessStatusPayload(request) { @@ -623,7 +625,8 @@ class AccessController { }, apiKey: authMethod === AUTH_METHOD_USER_BILLING_API_KEY ? { id: principal.keyId ?? null, keyPrefix: redactedTokenPrefix(secret), name: "user-billing", displaySecret: secret } : null, authMethod, - userBilling: { active: true, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true } + userBilling: { active: true, token: secret, principal: { ...principal, keyId: principal.keyId ?? null }, valuesRedacted: true }, + ...canonicalPrincipalFromAuth({ actor: synced, authMethod }) }; } @@ -821,11 +824,7 @@ class AccessController { } const { token, session } = await this.issueWebSessionForActor(user); setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS, request, this.env); - return sendJson(response, 200, { - authenticated: true, - actor: publicActor(user), - session: publicSession({ ...session, user }) - }); + return sendJson(response, 200, sessionResponseFromAuth({ actor: user, session: publicSession({ ...session, user }), authMethod: AUTH_METHOD_WEB_SESSION })); } async handleUserBillingRegister(request, response) { @@ -846,17 +845,10 @@ class AccessController { if (!login.ok) { return sendJson(response, login.status ?? 502, errorPayload(login.error?.code ?? "user_billing_login_failed", login.error?.message ?? "registration completed but login failed", login.status ?? 502)); } - return sendJson(response, 201, { + return sendJson(response, 201, sessionResponseFromAuth(login.auth, { created: true, - authenticated: true, - actor: publicActor(login.auth.actor), - user: publicActor(login.auth.actor), - session: login.auth.session, - authMethod: AUTH_METHOD_USER_BILLING_SESSION, - initialCredits: result.body?.initialCredits ?? null, - contractVersion: "user-billing-web-session-v1", - valuesRedacted: true - }); + initialCredits: result.body?.initialCredits ?? null + })); } async userBillingLogin(body, request, response) { @@ -872,15 +864,7 @@ class AccessController { response: sendJson(response, result.status ?? 502, userBillingDependencyErrorPayload(result, "user_billing_login_failed", "user-billing login failed", result.status ?? 502)) }; } - sendJson(response, 200, { - authenticated: true, - actor: publicActor(result.auth.actor), - user: publicActor(result.auth.actor), - session: result.auth.session, - authMethod: AUTH_METHOD_USER_BILLING_SESSION, - contractVersion: "user-billing-web-session-v1", - valuesRedacted: true - }); + sendJson(response, 200, sessionResponseFromAuth(result.auth)); return { handled: true }; } @@ -1463,6 +1447,55 @@ function publicActor(user) { return user ? { id: user.id, username: user.usernam function publicAuthMethod(method) { return [AUTH_METHOD_API_KEY, AUTH_METHOD_WEB_SESSION, AUTH_METHOD_LEGACY_LOCAL_SESSION, AUTH_METHOD_USER_BILLING_API_KEY, AUTH_METHOD_USER_BILLING_SESSION].includes(method) ? method : AUTH_METHOD_WEB_SESSION; } +function canonicalPrincipalFromAuth(auth = {}) { + const actor = auth.actor ?? null; + const authenticated = Boolean(actor); + const authMethod = authenticated ? publicAuthMethod(auth.authMethod ?? (auth.apiKey ? AUTH_METHOD_API_KEY : AUTH_METHOD_WEB_SESSION)) : null; + return { + authenticated, + authMethod, + identityAuthority: authenticated ? identityAuthorityForAuthMethod(authMethod) : null, + sessionKind: authenticated ? sessionKindForAuthMethod(authMethod) : null, + actor: authenticated ? publicActor(actor) : null, + capabilities: capabilitySummaryForPrincipal({ actor, authMethod, userBilling: auth.userBilling }), + valuesRedacted: true + }; +} +function sessionResponseFromAuth(auth = {}, extra = {}) { + const principal = canonicalPrincipalFromAuth(auth); + return { + ...principal, + user: principal.actor, + session: auth.session ?? null, + sessionExpiresAt: textOr(auth.session?.expiresAt ?? "", "") || null, + sessionTtlSeconds: SESSION_MAX_AGE_SECONDS, + contractVersion: "canonical-principal-v1", + ...extra + }; +} +function identityAuthorityForAuthMethod(authMethod) { + if (authMethod === AUTH_METHOD_USER_BILLING_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_SESSION) return "user-billing"; + if (authMethod === AUTH_METHOD_LEGACY_LOCAL_SESSION) return "bootstrap-recovery"; + return "hwlab-local"; +} +function sessionKindForAuthMethod(authMethod) { + if (authMethod === AUTH_METHOD_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_API_KEY) return "api-key"; + if (authMethod === AUTH_METHOD_LEGACY_LOCAL_SESSION) return "recovery"; + return "browser"; +} +function capabilitySummaryForPrincipal({ actor = null, authMethod = null, userBilling = null } = {}) { + const authenticated = Boolean(actor); + const admin = authenticated && actor?.role === "admin"; + const billingBacked = authenticated && (Boolean(userBilling?.active) || authMethod === AUTH_METHOD_USER_BILLING_API_KEY || authMethod === AUTH_METHOD_USER_BILLING_SESSION); + return { + web: authenticated ? "available" : "forbidden", + workbench: authenticated ? "available" : "forbidden", + codeAgent: authenticated ? "available" : "forbidden", + billing: billingBacked ? "available" : authenticated ? "unlinked" : "forbidden", + apiKeys: authenticated ? "available" : "forbidden", + admin: admin ? "available" : "forbidden" + }; +} function userBillingActor(principal = {}) { const id = textOr(principal.userId, ""); const username = textOr(principal.username, "") || localUserBillingUsername(id); diff --git a/internal/cloud/auth-canonical-principal.test.ts b/internal/cloud/auth-canonical-principal.test.ts new file mode 100644 index 00000000..0d52e715 --- /dev/null +++ b/internal/cloud/auth-canonical-principal.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; + +test("/auth/session returns structured unauthenticated canonical result", async () => { + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" } }); + await listen(server); + try { + const { port } = server.address() as { port: number }; + const response = await fetch(`http://127.0.0.1:${port}/auth/session`); + assert.equal(response.status, 401); + const body = await response.json(); + assert.equal(body.authenticated, false); + assert.equal(body.authMethod, null); + assert.equal(body.identityAuthority, null); + assert.equal(body.sessionKind, null); + assert.equal(body.actor, null); + assert.equal(body.valuesRedacted, true); + assert.equal(body.error.code, "auth_required"); + assert.equal(JSON.stringify(body).includes("hwlab_session="), false); + } finally { + await close(server); + } +}); + +test("/auth/session returns canonical principal for browser user-billing session", async () => { + const sessionToken = "hws_canonical_session_secret"; + const userBillingClient = { + configured: true, + async login(body: any = {}) { + assert.equal(body.username, "canonical-user"); + return { ok: true, status: 200, body: { token: sessionToken } }; + }, + async introspect(token: string) { + assert.equal(token, sessionToken); + return { + ok: true, + status: 200, + body: { + active: true, + principal: { + userId: "usr_canonical_web", + email: "canonical@hwlab.local", + username: "canonical-user", + role: "user", + status: "active", + scopes: ["session"], + authType: "session" + } + } + }; + } + }; + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, userBillingClient }); + await listen(server); + try { + const { port } = server.address() as { port: number }; + const login = await fetch(`http://127.0.0.1:${port}/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ username: "canonical-user", password: "canonical-password" }) + }); + assert.equal(login.status, 200); + const cookie = login.headers.get("set-cookie")?.split(";")[0] ?? ""; + assert.match(cookie, /^hwlab_session=hws_/u); + const loginBody = await login.json(); + assert.equal(loginBody.authMethod, "user-billing-session"); + assert.equal(loginBody.identityAuthority, "user-billing"); + assert.equal(loginBody.sessionKind, "browser"); + assert.equal(loginBody.capabilities.billing, "available"); + assert.equal(JSON.stringify(loginBody).includes(sessionToken), false); + + const response = await fetch(`http://127.0.0.1:${port}/auth/session`, { headers: { cookie } }); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.authenticated, true); + assert.equal(body.authMethod, "user-billing-session"); + assert.equal(body.identityAuthority, "user-billing"); + assert.equal(body.sessionKind, "browser"); + assert.equal(body.actor.id, "usr_canonical_web"); + assert.equal(body.actor.username, "canonical-user"); + assert.equal(body.capabilities.web, "available"); + assert.equal(body.capabilities.workbench, "available"); + assert.equal(body.capabilities.codeAgent, "available"); + assert.equal(body.capabilities.billing, "available"); + assert.equal(body.capabilities.apiKeys, "available"); + assert.equal(body.capabilities.admin, "forbidden"); + assert.equal(body.valuesRedacted, true); + assert.equal(JSON.stringify(body).includes(sessionToken), false); + } finally { + await close(server); + } +}); + +test("business billing route consumes authenticated canonical principal instead of re-reading request credential", async () => { + const delegatedToken = "hws_canonical_delegated_token"; + const userBillingClient = { + configured: true, + async billingSummary(token: string, options: any = {}) { + assert.equal(token, delegatedToken); + assert.equal(options.limit, 4); + return { ok: true, status: 200, body: { contractVersion: "user-billing-summary-v1", credits: { balance: 9 }, valuesRedacted: true } }; + } + }; + const accessController = { + required: true, + async authenticate() { + return { + ok: true, + authenticated: true, + actor: { id: "usr_canonical_consumer", username: "canonical-consumer", role: "user", status: "active" }, + session: { id: "uss_canonical_consumer", userId: "usr_canonical_consumer", tokenSource: "user-billing-session", valuesRedacted: true }, + authMethod: "user-billing-session", + identityAuthority: "user-billing", + sessionKind: "browser", + capabilities: { web: "available", workbench: "available", codeAgent: "available", billing: "available", apiKeys: "available", admin: "forbidden" }, + userBilling: { active: true, token: delegatedToken, principal: { userId: "usr_canonical_consumer", authType: "session" }, valuesRedacted: true }, + valuesRedacted: true + }; + } + }; + const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, accessController, userBillingClient }); + await listen(server); + try { + const { port } = server.address() as { port: number }; + const response = await fetch(`http://127.0.0.1:${port}/v1/billing/summary?limit=4`); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.credits.balance, 9); + assert.equal(body.proxy.source, "hwlab-user-billing"); + assert.equal(JSON.stringify(body).includes(delegatedToken), false); + } finally { + await close(server); + } +}); + +function listen(server: any) { + return new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +} + +function close(server: any) { + return new Promise((resolve, reject) => server.close((error: Error | undefined) => (error ? reject(error) : resolve()))); +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index ee34ceb7..cfa44c12 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1018,6 +1018,7 @@ async function codeAgentOptions(request, response, options, authOptions = {}) { authApiKey: auth.apiKey ?? null, actorApiKeySecret: auth.apiKey?.displaySecret ?? null, userBillingAuth: auth.userBilling ?? null, + userBillingToken: userBillingTokenFromAuth(auth), userBillingClient: options.userBillingClient ?? options.accessController?.userBilling ?? null }; } @@ -1027,7 +1028,7 @@ async function handleUsageSummaryHttp(request, response, url, options) { const nextOptions = await codeAgentOptions(request, response, options, { required: true }); if (!nextOptions) return; const client = nextOptions.userBillingClient; - const token = userBillingTokenFromRequest(request); + const token = nextOptions.userBillingToken; if (nextOptions.userBillingAuth?.active !== true || !token) { sendRestError(request, response, 403, "user_billing_auth_required", "usage summary requires a user-billing session or API key", { operation: `GET ${route}`, @@ -1139,7 +1140,7 @@ async function requireUserBillingClient(request, response, options, operation) { const nextOptions = await codeAgentOptions(request, response, options, { required: true }); if (!nextOptions) return null; const client = nextOptions.userBillingClient; - const token = userBillingTokenFromRequest(request); + const token = nextOptions.userBillingToken; if (nextOptions.userBillingAuth?.active !== true || !token) { sendRestError(request, response, 403, "user_billing_auth_required", "user-billing session or API key is required", { operation, @@ -1423,11 +1424,12 @@ async function handleAdminBillingUserStatusHttp(request, response, userId, optio } async function maybeHandleUserBillingApiKeysHttp(request, response, url, options) { - const token = userBillingTokenFromRequest(request); const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; - if (!token || !client?.configured) return false; + if (!client?.configured) return false; const auth = await options.accessController.authenticate(request, { required: true }); if (!auth.ok || !String(auth.authMethod ?? "").startsWith("user-billing")) return false; + const token = userBillingTokenFromAuth(auth); + if (!token) return false; if (url.pathname === "/v1/api-keys/default") { sendRestError(request, response, 404, "user_billing_default_key_unsupported", "user-billing API keys do not support legacy default-key regeneration", { operation: `${request.method} /v1/api-keys/default`, @@ -1466,10 +1468,10 @@ async function maybeHandleUserBillingApiKeysHttp(request, response, url, options } async function handleUserBillingProfileHttp(request, response, options) { - const token = userBillingTokenFromRequest(request); const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; const auth = await options.accessController.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); + const token = userBillingTokenFromAuth(auth); if (!String(auth.authMethod ?? "").startsWith("user-billing")) { return sendRestError(request, response, 403, "user_billing_auth_required", "profile update requires a user-billing bearer token", { operation: "PATCH /v1/users/me/profile", target: { type: "route", id: "/v1/users/me/profile" } }); } @@ -1483,10 +1485,10 @@ async function handleUserBillingProfileHttp(request, response, options) { } async function handleUserBillingPasswordHttp(request, response, options) { - const token = userBillingTokenFromRequest(request); const client = options.userBillingClient ?? options.accessController?.userBilling ?? null; const auth = await options.accessController.authenticate(request, { required: true }); if (!auth.ok) return sendJson(response, auth.status, auth); + const token = userBillingTokenFromAuth(auth); if (auth.authMethod !== "user-billing-session") { return sendRestError(request, response, 403, "user_billing_session_required", "password update requires a user-billing session token", { operation: "POST /v1/users/me/password", target: { type: "route", id: "/v1/users/me/password" } }); } @@ -1581,6 +1583,11 @@ function userBillingTokenFromRequest(request) { return cookieToken.startsWith("hws_") ? cookieToken : ""; } +function userBillingTokenFromAuth(auth) { + const token = String(auth?.userBilling?.token ?? "").trim(); + return token ? token : ""; +} + function cookieValueFromRequest(request, name) { const target = String(name ?? ""); if (!target) return ""; diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index a5b48243..6d587877 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -79,7 +79,10 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse) return json(response, 200, stateSummary()); } - if (path === "/auth/session" || path === "/auth/bootstrap") return json(response, 200, authPayload()); + if (path === "/auth/session" || path === "/auth/bootstrap") { + const payload = authPayload(); + return json(response, payload.authenticated === true ? 200 : 401, payload); + } if (path === "/auth/login" && method === "POST") return authLoginResponse(response); if (path === "/v1/workbench/events" && method === "GET") return sse(response, url); if (path === "/v1/workbench/sessions" && method === "GET") { @@ -620,8 +623,12 @@ function isLegacyWorkbenchPath(path: string): boolean { } function authPayload(): JsonRecord { - if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") return { authenticated: false, mode: "server", actor: null, user: null, expiresAt: null }; - return { authenticated: true, mode: "server", actor: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, user: { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }, expiresAt: null }; + const deniedCapabilities = { web: "forbidden", workbench: "forbidden", codeAgent: "forbidden", billing: "forbidden", apiKeys: "forbidden", admin: "forbidden" }; + if (state.scenarioId === "auth-upstream-unavailable" || state.scenarioId === "auth-invalid-credentials") { + return { authenticated: false, mode: "server", authMethod: null, identityAuthority: null, sessionKind: null, actor: null, user: null, capabilities: deniedCapabilities, expiresAt: null, valuesRedacted: true, error: { code: "auth_required", message: "Authentication is required", status: 401 } }; + } + const actor = { id: "usr_e2e_admin", username: "e2e-admin", role: "admin", status: "active" }; + return { authenticated: true, mode: "server", authMethod: "web-session", identityAuthority: "hwlab-local", sessionKind: "browser", actor, user: actor, capabilities: { web: "available", workbench: "available", codeAgent: "available", billing: "unlinked", apiKeys: "available", admin: "available" }, expiresAt: null, valuesRedacted: true }; } function authLoginResponse(response: ServerResponse): void { diff --git a/web/hwlab-cloud-web/src/stores/auth.ts b/web/hwlab-cloud-web/src/stores/auth.ts index 22c2b1c1..b3fcda5c 100644 --- a/web/hwlab-cloud-web/src/stores/auth.ts +++ b/web/hwlab-cloud-web/src/stores/auth.ts @@ -1,7 +1,7 @@ import { computed, ref } from "vue"; import { defineStore } from "pinia"; import { authAPI, HWLAB_WEB_SESSION_COOKIE } from "@/api"; -import type { AuthSession, AuthState, AuthUser } from "@/types"; +import type { AuthCapabilities, AuthCapabilityStatus, AuthSession, AuthState, AuthUser } from "@/types"; import { asRecord, nonEmptyString } from "@/utils"; export const useAuthStore = defineStore("auth", () => { @@ -12,7 +12,7 @@ export const useAuthStore = defineStore("auth", () => { const isAuthenticated = computed(() => authState.value === "authenticated" && session.value?.authenticated === true); const user = computed(() => session.value?.user ?? session.value?.actor ?? null); - const isAdmin = computed(() => user.value?.role === "admin"); + const isAdmin = computed(() => session.value?.capabilities?.admin === "available" || user.value?.role === "admin"); async function bootstrap(): Promise { if (authState.value !== "checking") checked.value = true; @@ -73,8 +73,19 @@ export const useAuthStore = defineStore("auth", () => { return { authState, checked, session, error, isAuthenticated, user, isAdmin, bootstrap, login, register, logout, cookieName: HWLAB_WEB_SESSION_COOKIE }; }); -function sessionFromPayload(payload: { authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string | null }): AuthSession { - return { authenticated: payload.authenticated === true, mode: "server", user: userFromUnknown(payload.user), actor: userFromUnknown(payload.actor), expiresAt: nonEmptyString(payload.expiresAt) }; +function sessionFromPayload(payload: { authenticated?: boolean; authMethod?: unknown; identityAuthority?: unknown; sessionKind?: unknown; user?: unknown; actor?: unknown; capabilities?: unknown; expiresAt?: string | null; sessionExpiresAt?: string | null; valuesRedacted?: unknown }): AuthSession { + return { + authenticated: payload.authenticated === true, + mode: "server", + authMethod: nonEmptyString(payload.authMethod) ?? null, + identityAuthority: nonEmptyString(payload.identityAuthority) ?? null, + sessionKind: nonEmptyString(payload.sessionKind) ?? null, + user: userFromUnknown(payload.user), + actor: userFromUnknown(payload.actor), + capabilities: capabilitiesFromUnknown(payload.capabilities), + expiresAt: nonEmptyString(payload.expiresAt) ?? nonEmptyString(payload.sessionExpiresAt), + valuesRedacted: payload.valuesRedacted === true + }; } function userFromUnknown(value: unknown): AuthUser | undefined { @@ -83,6 +94,17 @@ function userFromUnknown(value: unknown): AuthUser | undefined { return { id: nonEmptyString(record.id) ?? undefined, username: nonEmptyString(record.username) ?? undefined, name: nonEmptyString(record.name) ?? undefined, displayName: nonEmptyString(record.displayName) ?? undefined, role: nonEmptyString(record.role) ?? undefined, status: nonEmptyString(record.status) ?? undefined }; } +function capabilitiesFromUnknown(value: unknown): AuthCapabilities | undefined { + const record = asRecord(value); + if (!record) return undefined; + const output: AuthCapabilities = {}; + for (const [key, item] of Object.entries(record)) { + const status = nonEmptyString(item) as AuthCapabilityStatus | undefined; + if (status) output[key] = status; + } + return Object.keys(output).length > 0 ? output : undefined; +} + function loginFailureMessage(response: Awaited>): string { const code = loginErrorCode(response.data) ?? nonEmptyString(response.error); const normalizedCode = code?.trim().toLowerCase().replace(/_/gu, "-") ?? ""; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 7570a660..137dbcb0 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -19,12 +19,29 @@ export interface AuthUser { status?: AccessUserStatus; } +export type AuthCapabilityStatus = "available" | "forbidden" | "unlinked" | "unavailable" | string; + +export interface AuthCapabilities { + web?: AuthCapabilityStatus; + workbench?: AuthCapabilityStatus; + codeAgent?: AuthCapabilityStatus; + billing?: AuthCapabilityStatus; + apiKeys?: AuthCapabilityStatus; + admin?: AuthCapabilityStatus; + [key: string]: AuthCapabilityStatus | undefined; +} + export interface AuthSession { authenticated: boolean; mode: "server"; + authMethod?: string | null; + identityAuthority?: string | null; + sessionKind?: string | null; user?: AuthUser; actor?: AuthUser; + capabilities?: AuthCapabilities; expiresAt?: string | null; + valuesRedacted?: boolean; } export interface ApiResult {