From a8b7290f346c6670be004f7b3c65ed229196bd0d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 19 Jul 2026 00:58:43 -0700 Subject: [PATCH] feat(gateway): durable user profiles with email aliases and avatars (#111224) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(gateway): durable user profiles with email aliases and avatars * fix(gateway): compress merge tombstones, content-hash avatar ETags, users CLI json output * fix(gateway): lean profile listing, scoped avatar routing, typed email validation * fix(gateway): protocol-complete profile payloads and consistent store reads * fix(gateway): mark profile schema ensured only after commit * fix(gateway): avatar endpoint HEAD support and RFC If-None-Match * fix(gateway): profiles CI conformance — bindings, lint, knip, sql boundary * feat(gateway): self-service profile edits for authenticated users * fix(gateway): users.self bootstrap, tombstone-aware ownership, escaped CLI output * fix(gateway): raw-DDL allowlist entry and lean profile exports --- .../openclaw/app/gateway/GatewayProtocol.kt | 5 + packages/gateway-protocol/src/index.ts | 42 ++ packages/gateway-protocol/src/schema.ts | 1 + packages/gateway-protocol/src/schema/users.ts | 61 +++ scripts/check-kysely-guardrails.mjs | 1 + src/cli/program/register.subclis-core.ts | 5 + src/cli/program/subcli-descriptors.ts | 6 + src/cli/users-cli.test.ts | 74 +++ src/cli/users-cli.ts | 77 +++ src/gateway/method-scopes.test.ts | 5 + src/gateway/methods/core-descriptors.ts | 5 + src/gateway/server-http.ts | 23 + .../server-methods.authorization.test.ts | 120 +++++ src/gateway/server-methods.ts | 14 + src/gateway/server-methods/users.test.ts | 232 +++++++++ src/gateway/server-methods/users.ts | 181 +++++++ src/gateway/user-profiles-http-path.ts | 13 + src/gateway/user-profiles-http.test.ts | 148 ++++++ src/gateway/user-profiles-http.ts | 79 +++ src/state/user-profiles-schema.ts | 23 + src/state/user-profiles.test.ts | 220 ++++++++ src/state/user-profiles.ts | 490 ++++++++++++++++++ 22 files changed, 1825 insertions(+) create mode 100644 packages/gateway-protocol/src/schema/users.ts create mode 100644 src/cli/users-cli.test.ts create mode 100644 src/cli/users-cli.ts create mode 100644 src/gateway/server-methods/users.test.ts create mode 100644 src/gateway/server-methods/users.ts create mode 100644 src/gateway/user-profiles-http-path.ts create mode 100644 src/gateway/user-profiles-http.test.ts create mode 100644 src/gateway/user-profiles-http.ts create mode 100644 src/state/user-profiles-schema.ts create mode 100644 src/state/user-profiles.test.ts create mode 100644 src/state/user-profiles.ts diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index d248a5d9ef06..d512336893b1 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -239,6 +239,11 @@ enum class GatewayMethod( BoardEvent("board.event"), AuditList("audit.list"), AuditActivityList("audit.activity.list"), + UsersList("users.list"), + UsersSelf("users.self"), + UsersLinkEmail("users.linkEmail"), + UsersSetDisplayName("users.setDisplayName"), + UsersSetAvatar("users.setAvatar"), TasksList("tasks.list"), TasksGet("tasks.get"), TasksCancel("tasks.cancel"), diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 8ca32c7074a8..d1e7827b7309 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -36,6 +36,17 @@ import { AuditEventSchema, AuditListParamsSchema, AuditListResultSchema, + UserProfileSchema, + UsersLinkEmailParamsSchema, + UsersLinkEmailResultSchema, + UsersListParamsSchema, + UsersListResultSchema, + UsersSelfParamsSchema, + UsersSelfResultSchema, + UsersSetAvatarParamsSchema, + UsersSetAvatarResultSchema, + UsersSetDisplayNameParamsSchema, + UsersSetDisplayNameResultSchema, AgentIdentityParamsSchema, AgentIdentityResultSchema, AgentParamsSchema, @@ -632,6 +643,15 @@ export const validateAuditActivityListParams = lazyCompile; +export type UsersListParams = Static; +export type UsersListResult = Static; +export type UsersSelfParams = Static; +export type UsersSelfResult = Static; +export type UsersLinkEmailParams = Static; +export type UsersLinkEmailResult = Static; +export type UsersSetDisplayNameParams = Static; +export type UsersSetDisplayNameResult = Static; +export type UsersSetAvatarParams = Static; +export type UsersSetAvatarResult = Static; diff --git a/scripts/check-kysely-guardrails.mjs b/scripts/check-kysely-guardrails.mjs index d629a70d9245..657139a2198f 100644 --- a/scripts/check-kysely-guardrails.mjs +++ b/scripts/check-kysely-guardrails.mjs @@ -94,6 +94,7 @@ const rawSqliteAllowPathGroups = { "shared database stores with direct DatabaseSync access": ["src/proxy-capture/store.sqlite.ts"], "Kysely-backed stores that own a DatabaseSync boundary": [ "src/acp/event-ledger.ts", + "src/state/user-profiles.ts", "src/cron/store.ts", "src/infra/outbound/current-conversation-bindings.ts", "src/media/store.ts", diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index f0aab836dbd5..6f9ebbf33301 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -154,6 +154,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ loadModule: () => import("../devices-cli.js"), exportName: "registerDevicesCli", }, + { + commandNames: ["users"], + loadModule: () => import("../users-cli.js"), + exportName: "registerUsersCli", + }, { commandNames: ["node"], loadModule: () => import("../node-cli.js"), diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index c745502cc200..04b72eeac39f 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -71,6 +71,12 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([ hasSubcommands: true, parentDefaultHelp: true, }, + { + name: "users", + description: "Manage durable user profiles and email aliases", + hasSubcommands: true, + parentDefaultHelp: true, + }, { name: "node", description: "Run and manage the headless node host service", diff --git a/src/cli/users-cli.test.ts b/src/cli/users-cli.test.ts new file mode 100644 index 000000000000..e7e4a0b4685f --- /dev/null +++ b/src/cli/users-cli.test.ts @@ -0,0 +1,74 @@ +import { Command } from "commander"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerUsersCli, testApi } from "./users-cli.js"; + +const callGatewayFromCli = vi.hoisted(() => vi.fn()); +vi.mock("./gateway-rpc.js", () => ({ callGatewayFromCli })); + +afterEach(() => { + vi.restoreAllMocks(); + callGatewayFromCli.mockReset(); +}); + +describe("registerUsersCli", () => { + it("routes link-email through the admin gateway method", async () => { + const program = new Command().exitOverride(); + registerUsersCli(program); + + await program.parseAsync([ + "node", + "openclaw", + "users", + "link-email", + "Ada@example.com", + "--to", + "p-1", + ]); + + expect(callGatewayFromCli).toHaveBeenCalledWith( + "users.linkEmail", + expect.objectContaining({ to: "p-1" }), + { email: "Ada@example.com", targetProfileId: "p-1" }, + { scopes: ["operator.admin"] }, + ); + }); + + it("prints the link result as JSON when requested", async () => { + const output = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + callGatewayFromCli.mockResolvedValue({ profile: { id: "p-1" } }); + const program = new Command().exitOverride(); + registerUsersCli(program); + + await program.parseAsync([ + "node", + "openclaw", + "users", + "link-email", + "Ada@example.com", + "--to", + "p-1", + "--json", + ]); + + expect(output).toHaveBeenCalledWith('{\n "profile": {\n "id": "p-1"\n }\n}\n'); + }); + + it("escapes untrusted profile fields in human list output", () => { + const output = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + + testApi.writeUsersList( + { + profiles: [ + { + id: "p-1", + displayName: "Ada\n\t\u001b[2J\u0007", + emails: ["ada@example.com\nnext@example.com"], + }, + ], + }, + false, + ); + + expect(output).toHaveBeenCalledWith("p-1\tAda\\n\\t\tada@example.com\\nnext@example.com\n"); + }); +}); diff --git a/src/cli/users-cli.ts b/src/cli/users-cli.ts new file mode 100644 index 000000000000..9ca051723ee0 --- /dev/null +++ b/src/cli/users-cli.ts @@ -0,0 +1,77 @@ +// Minimal gateway CLI commands for durable user profile administration. +import type { Command } from "commander"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; +import { callGatewayFromCli, type GatewayRpcOpts } from "./gateway-rpc.js"; +import { applyParentDefaultHelpAction } from "./program/parent-default-help.js"; + +type UsersCliOpts = GatewayRpcOpts & { to?: string }; + +const DEFAULT_USERS_TIMEOUT_MS = 10_000; + +function addUsersGatewayOptions(command: Command) { + return command + .option("--url ", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)") + .option("--token ", "Gateway token (if required)") + .option("--timeout ", "Timeout in ms", String(DEFAULT_USERS_TIMEOUT_MS)) + .option("--json", "Output JSON", false); +} + +type UsersListResult = { + profiles?: Array<{ id?: string; displayName?: string | null; emails?: string[] }>; +}; + +function writeUsersList(result: unknown, json: boolean): void { + if (json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + return; + } + const profiles = (result as UsersListResult).profiles ?? []; + for (const profile of profiles) { + process.stdout.write( + `${sanitizeTerminalText(profile.id ?? "")}\t${sanitizeTerminalText(profile.displayName ?? "")}\t${sanitizeTerminalText((profile.emails ?? []).join(","))}\n`, + ); + } +} + +export function registerUsersCli(program: Command) { + const users = program + .command("users") + .description("Manage durable user profiles and email aliases"); + + addUsersGatewayOptions( + users + .command("list") + .description("List durable user profiles") + .action(async (opts: UsersCliOpts) => { + const result = await callGatewayFromCli( + "users.list", + opts, + {}, + { scopes: ["operator.read"] }, + ); + writeUsersList(result, opts.json === true); + }), + ); + + addUsersGatewayOptions( + users + .command("link-email ") + .description("Link an email alias to a user profile") + .requiredOption("--to ", "Target profile id") + .action(async (email: string, opts: UsersCliOpts) => { + const result = await callGatewayFromCli( + "users.linkEmail", + opts, + { email, targetProfileId: opts.to }, + { scopes: ["operator.admin"] }, + ); + if (opts.json) { + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + } + }), + ); + + applyParentDefaultHelpAction(users); +} + +export const testApi = { writeUsersList }; diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 2f69e65a059f..75945378fef2 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -44,6 +44,11 @@ describe("method scope resolution", () => { ["tasks.list", ["operator.read"]], ["audit.activity.list", ["operator.read"]], ["audit.list", ["operator.read"]], + ["users.list", ["operator.read"]], + ["users.self", ["operator.write"]], + ["users.linkEmail", ["operator.admin"]], + ["users.setDisplayName", ["operator.write"]], + ["users.setAvatar", ["operator.write"]], ["tasks.get", ["operator.read"]], ["taskSuggestions.list", ["operator.read"]], ["taskSuggestions.create", ["operator.write"]], diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 00888190c069..022a113200bd 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -123,6 +123,11 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "board.event", scope: "operator.write" }, { name: "audit.list", scope: "operator.read" }, { name: "audit.activity.list", scope: "operator.read" }, + { name: "users.list", scope: "operator.read" }, + { name: "users.self", scope: "operator.write" }, + { name: "users.linkEmail", scope: "operator.admin" }, + { name: "users.setDisplayName", scope: "operator.write" }, + { name: "users.setAvatar", scope: "operator.write" }, { name: "tasks.list", scope: "operator.read" }, { name: "tasks.get", scope: "operator.read" }, { name: "tasks.cancel", scope: "operator.write" }, diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 82622e72c374..ea7a64e67052 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -62,6 +62,7 @@ import { type GatewayIngressWebSocket, type GatewayWsClient, } from "./server/ws-types.js"; +import { matchUserProfileAvatarPath } from "./user-profiles-http-path.js"; type PluginHttpRequestHandler = ( req: IncomingMessage, @@ -124,6 +125,8 @@ const getSessionKillHttpModule = createLazyRuntimeModule(() => import("./session const getToolsInvokeHttpModule = createLazyRuntimeModule(() => import("./tools-invoke-http.js")); +const getUserProfilesHttpModule = createLazyRuntimeModule(() => import("./user-profiles-http.js")); + const getPluginNodeCapabilityAuthModule = createLazyRuntimeModule( () => import("./server/plugin-node-capability-auth.js"), ); @@ -148,6 +151,7 @@ function isControlUiCatalogIconRequest(pathname: string, basePath: string): bool pathname.startsWith(`${normalizedBasePath}${prefix}/`), ); } + const pluginGatewayAuthBypassPathsCache = new WeakMap< OpenClawConfig, Promise> @@ -697,6 +701,25 @@ export function createGatewayHttpServer(opts: { ), }); } + if (matchUserProfileAvatarPath(scopedRequestPath) !== undefined) { + requestStages.push({ + name: "user-profile-avatar", + run: async () => + await runWithGatewayHttpWorkAdmission(res, async () => + (await getUserProfilesHttpModule()).handleUserProfileAvatarHttpRequest( + req, + res, + scopedRequestPath, + { + auth: resolvedAuthValue, + trustedProxies, + allowRealIpFallback, + rateLimiter, + }, + ), + ), + }); + } if (openResponsesEnabled && isOpenResponsesPath(scopedRequestPath)) { requestStages.push({ name: "openresponses", diff --git a/src/gateway/server-methods.authorization.test.ts b/src/gateway/server-methods.authorization.test.ts index 3c6a23b8dde3..f736e11c79ca 100644 --- a/src/gateway/server-methods.authorization.test.ts +++ b/src/gateway/server-methods.authorization.test.ts @@ -9,9 +9,26 @@ import { handleGatewayRequest } from "./server-methods.js"; import type { GatewayRequestHandler } from "./server-methods/types.js"; const METHOD = "workboard.cards.dispatch"; +const ensureProfileForEmail = vi.hoisted(() => vi.fn()); +const resolveUserProfileId = vi.hoisted(() => vi.fn()); +const setDisplayName = vi.hoisted(() => vi.fn()); + +vi.mock("../state/user-profiles.js", () => ({ + ensureProfileForEmail, + getUserProfileListItem: vi.fn(), + linkEmail: vi.fn(), + listProfiles: vi.fn(), + resolveUserProfileId, + setAvatar: vi.fn(), + setDisplayName, + UserProfileNotFoundError: class UserProfileNotFoundError extends Error {}, +})); afterEach(() => { setActivePluginRegistry(createEmptyPluginRegistry()); + ensureProfileForEmail.mockReset(); + resolveUserProfileId.mockReset(); + setDisplayName.mockReset(); }); describe("gateway method authorization", () => { @@ -66,4 +83,107 @@ describe("gateway method authorization", () => { }, }); }); + + async function dispatchProfileMutation(params: { + authenticatedUserId?: string; + profileId: string; + scopes: string[]; + }) { + const respond = vi.fn(); + await handleGatewayRequest({ + req: { + type: "req", + id: "req-users-1", + method: "users.setDisplayName", + params: { displayName: "Ada", profileId: params.profileId }, + }, + respond, + client: { + connId: "conn-users-1", + ...(params.authenticatedUserId ? { authenticatedUserId: params.authenticatedUserId } : {}), + connect: { + role: "operator", + scopes: params.scopes, + client: { id: "test", version: "1", platform: "test", mode: "test" }, + minProtocol: 1, + maxProtocol: 1, + }, + } as Parameters[0]["client"], + isWebchatConnect: () => false, + context: { logGateway: { warn: vi.fn() } } as unknown as Parameters< + typeof handleGatewayRequest + >[0]["context"], + }); + return respond; + } + + it("admits write-scoped requests for handler-level self-service authorization", async () => { + const respond = await dispatchProfileMutation({ + profileId: "profile-1", + scopes: ["operator.write"], + }); + + expect(respond).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "FORBIDDEN" }), + ); + }); + + it("rejects profile mutations before the handler without write scope", async () => { + const respond = await dispatchProfileMutation({ + profileId: "profile-1", + scopes: ["operator.read"], + }); + + expect(respond).toHaveBeenCalledWith(false, undefined, { + code: "FORBIDDEN", + message: "missing scope: operator.write", + details: { + code: "MISSING_SCOPE", + missingScope: "operator.write", + requiredScopes: ["operator.write"], + }, + }); + }); + + it("allows an identified write caller to edit its own profile", async () => { + const profile = { id: "profile-1" }; + ensureProfileForEmail.mockReturnValue(profile); + resolveUserProfileId.mockReturnValue(profile.id); + setDisplayName.mockReturnValue(profile); + + expect( + await dispatchProfileMutation({ + authenticatedUserId: "ada@example.com", + profileId: "profile-1", + scopes: ["operator.write"], + }), + ).toHaveBeenCalledWith(true, { profile }); + }); + + it("requires admin when an identified write caller targets another profile", async () => { + ensureProfileForEmail.mockReturnValue({ id: "profile-1" }); + resolveUserProfileId.mockReturnValue("profile-2"); + + expect( + await dispatchProfileMutation({ + authenticatedUserId: "ada@example.com", + profileId: "profile-2", + scopes: ["operator.write"], + }), + ).toHaveBeenCalledWith(false, undefined, expect.objectContaining({ code: "FORBIDDEN" })); + }); + + it("allows an admin caller to edit any profile", async () => { + const profile = { id: "profile-2" }; + setDisplayName.mockReturnValue(profile); + + expect( + await dispatchProfileMutation({ + profileId: "profile-2", + scopes: ["operator.admin"], + }), + ).toHaveBeenCalledWith(true, { profile }); + }); }); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index 9b6de7f2eb7e..33d2d42df5f8 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -69,6 +69,10 @@ const loadAuditHandlers = lazyHandlerModule( () => import("./server-methods/audit.js"), (module) => module.auditHandlers, ); +const loadUsersHandlers = lazyHandlerModule( + () => import("./server-methods/users.js"), + (module) => module.usersHandlers, +); const loadAttachHandlers = lazyHandlerModule( () => import("./server-methods/attach.js"), (module) => module.attachHandlers, @@ -587,6 +591,16 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { methods: ["audit.list", "audit.activity.list"], loadHandlers: loadAuditHandlers, }), + ...createLazyCoreHandlers({ + methods: [ + "users.list", + "users.self", + "users.linkEmail", + "users.setDisplayName", + "users.setAvatar", + ], + loadHandlers: loadUsersHandlers, + }), ...createLazyCoreHandlers({ methods: ["tasks.list", "tasks.get", "tasks.cancel"], loadHandlers: loadTasksHandlers, diff --git a/src/gateway/server-methods/users.test.ts b/src/gateway/server-methods/users.test.ts new file mode 100644 index 000000000000..c9fc9cf99717 --- /dev/null +++ b/src/gateway/server-methods/users.test.ts @@ -0,0 +1,232 @@ +import { expectDefined } from "@openclaw/normalization-core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + validateUsersLinkEmailResult, + validateUsersSelfResult, + validateUsersSetAvatarResult, + validateUsersSetDisplayNameResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import { usersHandlers } from "./users.js"; + +const linkEmail = vi.hoisted(() => vi.fn()); +const listProfiles = vi.hoisted(() => vi.fn()); +const setAvatar = vi.hoisted(() => vi.fn()); +const setDisplayName = vi.hoisted(() => vi.fn()); +const ensureProfileForEmail = vi.hoisted(() => vi.fn()); +const getUserProfileListItem = vi.hoisted(() => vi.fn()); +const resolveUserProfileId = vi.hoisted(() => vi.fn()); + +vi.mock("../../state/user-profiles.js", () => ({ + ensureProfileForEmail, + getUserProfileListItem, + linkEmail, + listProfiles, + resolveUserProfileId, + setAvatar, + setDisplayName, + UserProfileNotFoundError: class UserProfileNotFoundError extends Error {}, +})); + +async function runUsersHandler( + method: keyof typeof usersHandlers, + params: object, + client?: object, +) { + const respond = vi.fn(); + await expectDefined( + usersHandlers[method], + `${method} test invariant`, + )({ client, params, respond } as never); + return respond; +} + +describe("users gateway methods", () => { + const profile = { + id: "profile-1", + displayName: "Ada", + avatarMime: null, + mergedInto: null, + createdAt: 1, + updatedAt: 1, + emails: ["ada@example.com"], + hasAvatar: false, + }; + const adminClient = { connect: { scopes: ["operator.admin"] } }; + const selfClient = { + authenticatedUserId: "ada@example.com", + connect: { scopes: ["operator.write"] }, + }; + + beforeEach(() => { + ensureProfileForEmail.mockReset(); + getUserProfileListItem.mockReset(); + resolveUserProfileId.mockReset(); + linkEmail.mockReset(); + listProfiles.mockReset(); + setAvatar.mockReset(); + setDisplayName.mockReset(); + }); + + it("lists profiles through the read method", async () => { + listProfiles.mockReturnValue([{ id: "profile-1" }]); + + expect(await runUsersHandler("users.list", {})).toHaveBeenCalledWith(true, { + profiles: [{ id: "profile-1" }], + }); + }); + + it("creates and returns the caller's profile idempotently", async () => { + ensureProfileForEmail.mockReturnValue({ id: profile.id }); + getUserProfileListItem.mockReturnValue(profile); + + const first = await runUsersHandler("users.self", {}, selfClient); + const second = await runUsersHandler("users.self", {}, selfClient); + + expect(first).toHaveBeenCalledWith(true, { profile }); + expect(second).toHaveBeenCalledWith(true, { profile }); + expect(validateUsersSelfResult(first.mock.calls[0]?.[1])).toBe(true); + expect(ensureProfileForEmail).toHaveBeenNthCalledWith(1, "ada@example.com"); + expect(ensureProfileForEmail).toHaveBeenNthCalledWith(2, "ada@example.com"); + expect(getUserProfileListItem).toHaveBeenNthCalledWith(1, profile.id); + expect(getUserProfileListItem).toHaveBeenNthCalledWith(2, profile.id); + }); + + it("rejects users.self without an authenticated user", async () => { + expect( + await runUsersHandler("users.self", {}, { connect: { scopes: ["operator.write"] } }), + ).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ + code: "FORBIDDEN", + message: "users.self requires an authenticated user", + }), + ); + expect(ensureProfileForEmail).not.toHaveBeenCalled(); + }); + + it("validates and routes email links", async () => { + linkEmail.mockReturnValue(profile); + + const respond = await runUsersHandler("users.linkEmail", { + email: "ada@example.com", + targetProfileId: "profile-1", + }); + + expect(respond).toHaveBeenCalledWith(true, { profile }); + expect(validateUsersLinkEmailResult(respond.mock.calls[0]?.[1])).toBe(true); + expect(linkEmail).toHaveBeenCalledWith("ada@example.com", "profile-1"); + }); + + it("returns protocol-complete display name mutations", async () => { + setDisplayName.mockReturnValue(profile); + + const respond = await runUsersHandler( + "users.setDisplayName", + { + profileId: "profile-1", + displayName: "Ada", + }, + adminClient, + ); + + expect(validateUsersSetDisplayNameResult(respond.mock.calls[0]?.[1])).toBe(true); + }); + + it("returns protocol-complete avatar mutations", async () => { + setAvatar.mockReturnValue({ + ok: true, + value: { ...profile, avatarMime: "image/png", hasAvatar: true }, + }); + + const respond = await runUsersHandler( + "users.setAvatar", + { + profileId: "profile-1", + mime: "image/png", + avatarBase64: "AQ==", + }, + adminClient, + ); + + expect(validateUsersSetAvatarResult(respond.mock.calls[0]?.[1])).toBe(true); + }); + + it("rejects blank email aliases as invalid requests", async () => { + expect( + await runUsersHandler("users.linkEmail", { + email: " ", + targetProfileId: "profile-1", + }), + ).toHaveBeenCalledWith( + false, + undefined, + expect.objectContaining({ code: "INVALID_REQUEST", message: "email must not be empty" }), + ); + expect(linkEmail).not.toHaveBeenCalled(); + }); + + it("rejects malformed avatar payloads before storage", async () => { + expect( + await runUsersHandler("users.setAvatar", { + profileId: "profile-1", + mime: "image/png", + avatarBase64: "not base64", + }), + ).toHaveBeenCalledWith(false, undefined, expect.objectContaining({ code: "INVALID_REQUEST" })); + expect(setAvatar).not.toHaveBeenCalled(); + }); + + it("returns avatar constraint failures as invalid requests", async () => { + setAvatar.mockReturnValue({ ok: false, error: { code: "avatar_too_large" } }); + + expect( + await runUsersHandler( + "users.setAvatar", + { + profileId: "profile-1", + mime: "image/png", + avatarBase64: "AQ==", + }, + adminClient, + ), + ).toHaveBeenCalledWith(false, undefined, expect.objectContaining({ code: "INVALID_REQUEST" })); + }); + + it("allows an identified write caller to edit its own profile", async () => { + ensureProfileForEmail.mockReturnValue(profile); + resolveUserProfileId.mockReturnValue(profile.id); + setDisplayName.mockReturnValue(profile); + setAvatar.mockReturnValue({ ok: true, value: profile }); + + const displayName = await runUsersHandler( + "users.setDisplayName", + { profileId: "profile-1", displayName: "Ada Lovelace" }, + selfClient, + ); + const avatar = await runUsersHandler( + "users.setAvatar", + { profileId: "profile-1", mime: "image/png", avatarBase64: "AQ==" }, + selfClient, + ); + + expect(displayName).toHaveBeenCalledWith(true, { profile }); + expect(avatar).toHaveBeenCalledWith(true, { profile }); + expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com"); + }); + + it("allows an owner to edit through a tombstoned durable profile id", async () => { + ensureProfileForEmail.mockReturnValue(profile); + resolveUserProfileId.mockReturnValue(profile.id); + setDisplayName.mockReturnValue(profile); + + expect( + await runUsersHandler( + "users.setDisplayName", + { profileId: "merged-profile-1", displayName: "Ada Lovelace" }, + selfClient, + ), + ).toHaveBeenCalledWith(true, { profile }); + expect(resolveUserProfileId).toHaveBeenCalledWith("merged-profile-1"); + }); +}); diff --git a/src/gateway/server-methods/users.ts b/src/gateway/server-methods/users.ts new file mode 100644 index 000000000000..03043bc2e7fd --- /dev/null +++ b/src/gateway/server-methods/users.ts @@ -0,0 +1,181 @@ +// Gateway methods for durable user profile administration. +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateUsersLinkEmailParams, + validateUsersListParams, + validateUsersSelfParams, + validateUsersSetAvatarParams, + validateUsersSetDisplayNameParams, +} from "../../../packages/gateway-protocol/src/index.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { + ensureProfileForEmail, + getUserProfileListItem, + linkEmail, + listProfiles, + resolveUserProfileId, + setAvatar, + setDisplayName, + UserProfileNotFoundError, +} from "../../state/user-profiles.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; +import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js"; + +function decodeBase64(value: string): Uint8Array | undefined { + const trimmed = value.trim(); + if ( + !trimmed || + trimmed.length % 4 !== 0 || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(trimmed) + ) { + return undefined; + } + return Buffer.from(trimmed, "base64"); +} + +function invalidParams(name: string, errors: Parameters[0]) { + return errorShape( + ErrorCodes.INVALID_REQUEST, + `invalid ${name} params: ${formatValidationErrors(errors)}`, + ); +} + +function profileError(error: unknown) { + if (error instanceof UserProfileNotFoundError) { + return errorShape(ErrorCodes.INVALID_REQUEST, error.message); + } + return errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error)); +} + +function canMutateProfile( + client: GatewayRequestHandlerOptions["client"], + profileId: string, +): boolean { + if (client?.connect.scopes?.includes(ADMIN_SCOPE)) { + return true; + } + const authenticatedUserId = client?.authenticatedUserId; + return authenticatedUserId + ? ensureProfileForEmail(authenticatedUserId).id === resolveUserProfileId(profileId) + : false; +} + +function requireProfileMutationAccess( + client: GatewayRequestHandlerOptions["client"], + profileId: string, + respond: GatewayRequestHandlerOptions["respond"], +): boolean { + // These methods are write-scoped so an identified caller can edit only its own profile; + // edits targeting any other profile remain admin-only. + if (canMutateProfile(client, profileId)) { + return true; + } + respond( + false, + undefined, + errorShape(ErrorCodes.FORBIDDEN, "profile edits require the owning user or operator.admin"), + ); + return false; +} + +export const usersHandlers: GatewayRequestHandlers = { + "users.list": ({ params, respond }) => { + if (!validateUsersListParams(params)) { + respond(false, undefined, invalidParams("users.list", validateUsersListParams.errors)); + return; + } + respond(true, { profiles: listProfiles() }); + }, + "users.self": ({ client, params, respond }) => { + if (!validateUsersSelfParams(params)) { + respond(false, undefined, invalidParams("users.self", validateUsersSelfParams.errors)); + return; + } + if (!client?.authenticatedUserId) { + respond( + false, + undefined, + errorShape(ErrorCodes.FORBIDDEN, "users.self requires an authenticated user"), + ); + return; + } + try { + const profile = ensureProfileForEmail(client.authenticatedUserId); + respond(true, { profile: getUserProfileListItem(profile.id) }); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, + "users.linkEmail": ({ params, respond }) => { + if (!validateUsersLinkEmailParams(params)) { + respond( + false, + undefined, + invalidParams("users.linkEmail", validateUsersLinkEmailParams.errors), + ); + return; + } + const email = params.email.trim(); + if (!email) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "email must not be empty")); + return; + } + try { + respond(true, { profile: linkEmail(email, params.targetProfileId) }); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, + "users.setDisplayName": ({ client, params, respond }) => { + if (!validateUsersSetDisplayNameParams(params)) { + respond( + false, + undefined, + invalidParams("users.setDisplayName", validateUsersSetDisplayNameParams.errors), + ); + return; + } + try { + if (!requireProfileMutationAccess(client, params.profileId, respond)) { + return; + } + respond(true, { profile: setDisplayName(params.profileId, params.displayName) }); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, + "users.setAvatar": ({ client, params, respond }) => { + if (!validateUsersSetAvatarParams(params)) { + respond( + false, + undefined, + invalidParams("users.setAvatar", validateUsersSetAvatarParams.errors), + ); + return; + } + const bytes = decodeBase64(params.avatarBase64); + if (!bytes) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "avatarBase64 must be base64"), + ); + return; + } + try { + if (!requireProfileMutationAccess(client, params.profileId, respond)) { + return; + } + const result = setAvatar(params.profileId, bytes, params.mime); + if (!result.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, result.error.code)); + return; + } + respond(true, { profile: result.value }); + } catch (error) { + respond(false, undefined, profileError(error)); + } + }, +}; diff --git a/src/gateway/user-profiles-http-path.ts b/src/gateway/user-profiles-http-path.ts new file mode 100644 index 000000000000..b34c97a1bd70 --- /dev/null +++ b/src/gateway/user-profiles-http-path.ts @@ -0,0 +1,13 @@ +const USER_PROFILE_AVATAR_PATH = /^\/api\/users\/([^/]+)\/avatar$/u; + +export function matchUserProfileAvatarPath(pathname: string): string | undefined { + const profileId = USER_PROFILE_AVATAR_PATH.exec(pathname)?.[1]; + if (!profileId) { + return undefined; + } + try { + return decodeURIComponent(profileId); + } catch { + return undefined; + } +} diff --git a/src/gateway/user-profiles-http.test.ts b/src/gateway/user-profiles-http.test.ts new file mode 100644 index 000000000000..25060b52ede9 --- /dev/null +++ b/src/gateway/user-profiles-http.test.ts @@ -0,0 +1,148 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleUserProfileAvatarHttpRequest } from "./user-profiles-http.js"; + +const authorizeScopedGatewayHttpRequestOrReply = vi.hoisted(() => vi.fn()); +const getProfileAvatar = vi.hoisted(() => vi.fn()); + +vi.mock("./http-utils.js", async (importOriginal) => ({ + ...(await importOriginal()), + authorizeScopedGatewayHttpRequestOrReply, +})); +vi.mock("../state/user-profiles.js", () => ({ + formatUserProfileAvatarEtag: (sha256: string, mime: string) => + `"${sha256}-${mime.slice("image/".length)}"`, + getProfileAvatar, +})); + +function response() { + const end = vi.fn(); + const setHeader = vi.fn(); + const writeHead = vi.fn(); + return { + end, + response: { end, setHeader, writeHead } as unknown as ServerResponse, + writeHead, + }; +} + +function request(path: string, headers: Record = {}) { + return { method: "GET", url: path, headers } as unknown as IncomingMessage; +} + +describe("profile avatar HTTP endpoint", () => { + beforeEach(() => { + authorizeScopedGatewayHttpRequestOrReply.mockReset(); + getProfileAvatar.mockReset(); + authorizeScopedGatewayHttpRequestOrReply.mockResolvedValue({}); + }); + + it("serves avatars with their stored MIME type and representation ETag", async () => { + getProfileAvatar.mockReturnValue({ + bytes: new Uint8Array([1, 2, 3]), + mime: "image/webp", + sha256: "first-hash", + updatedAt: 42, + }); + const res = response(); + + await handleUserProfileAvatarHttpRequest( + request("/ignored-by-handler"), + res.response, + "/api/users/profile-1/avatar", + { auth: {} as never }, + ); + + expect(authorizeScopedGatewayHttpRequestOrReply).toHaveBeenCalledWith( + expect.objectContaining({ operatorMethod: "users.list" }), + ); + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ "Content-Type": "image/webp", ETag: '"first-hash-webp"' }), + ); + expect(res.end).toHaveBeenCalledWith(new Uint8Array([1, 2, 3])); + }); + + it("answers a matching ETag without a body", async () => { + getProfileAvatar.mockReturnValue({ + bytes: new Uint8Array([1]), + mime: "image/png", + sha256: "current-hash", + updatedAt: 42, + }); + const res = response(); + + await handleUserProfileAvatarHttpRequest( + request("/ignored-by-handler", { "if-none-match": '"current-hash-png"' }), + res.response, + "/api/users/profile-1/avatar", + { auth: {} as never }, + ); + + expect(res.writeHead).toHaveBeenCalledWith(304, { ETag: '"current-hash-png"' }); + expect(res.end).toHaveBeenCalledWith(); + }); + + it("decodes profile IDs from the scoped pathname", async () => { + getProfileAvatar.mockReturnValue({ + bytes: new Uint8Array([1]), + mime: "image/png", + sha256: "current-hash", + updatedAt: 42, + }); + + await handleUserProfileAvatarHttpRequest( + request("/ignored-by-handler"), + response().response, + "/api/users/profile%2D1/avatar", + { auth: {} as never }, + ); + + expect(getProfileAvatar).toHaveBeenCalledWith("profile-1"); + }); + + it("serves HEAD as GET without a body", async () => { + getProfileAvatar.mockReturnValue({ + bytes: new Uint8Array([1, 2, 3]), + mime: "image/png", + sha256: "head-hash", + updatedAt: 42, + }); + const res = response(); + + await handleUserProfileAvatarHttpRequest( + { method: "HEAD", url: "/ignored-by-handler", headers: {} } as unknown as IncomingMessage, + res.response, + "/api/users/profile-1/avatar", + { auth: {} as never }, + ); + + expect(res.writeHead).toHaveBeenCalledWith( + 200, + expect.objectContaining({ "Content-Type": "image/png", ETag: '"head-hash-png"' }), + ); + expect(res.end).toHaveBeenCalledWith(undefined); + }); + + it.each(['W/"current-hash-png"', '"other", "current-hash-png"', "*"])( + "revalidates If-None-Match form %s", + async (header) => { + getProfileAvatar.mockReturnValue({ + bytes: new Uint8Array([1]), + mime: "image/png", + sha256: "current-hash", + updatedAt: 42, + }); + const res = response(); + + await handleUserProfileAvatarHttpRequest( + request("/ignored-by-handler", { "if-none-match": header }), + res.response, + "/api/users/profile-1/avatar", + { auth: {} as never }, + ); + + expect(res.writeHead).toHaveBeenCalledWith(304, { ETag: '"current-hash-png"' }); + }, + ); +}); diff --git a/src/gateway/user-profiles-http.ts b/src/gateway/user-profiles-http.ts new file mode 100644 index 000000000000..f1cf68ccdb2d --- /dev/null +++ b/src/gateway/user-profiles-http.ts @@ -0,0 +1,79 @@ +// Authenticated HTTP avatar serving for durable user profiles. +import type { IncomingMessage, ServerResponse } from "node:http"; +import { formatUserProfileAvatarEtag, getProfileAvatar } from "../state/user-profiles.js"; +import type { AuthRateLimiter } from "./auth-rate-limit.js"; +import type { ResolvedGatewayAuth } from "./auth.js"; +import { sendJson, sendMethodNotAllowed } from "./http-common.js"; +import { + authorizeScopedGatewayHttpRequestOrReply, + resolveSharedSecretHttpOperatorScopes, +} from "./http-utils.js"; +import { matchUserProfileAvatarPath } from "./user-profiles-http-path.js"; + +/** Serves a profile avatar with the same HTTP operator auth as sibling gateway endpoints. */ +export async function handleUserProfileAvatarHttpRequest( + req: IncomingMessage, + res: ServerResponse, + pathname: string, + opts: { + auth: ResolvedGatewayAuth; + trustedProxies?: string[]; + allowRealIpFallback?: boolean; + rateLimiter?: AuthRateLimiter; + }, +): Promise { + const profileId = matchUserProfileAvatarPath(pathname); + if (profileId === undefined) { + return false; + } + const method = req.method; + if (method !== "GET" && method !== "HEAD") { + sendMethodNotAllowed(res, "GET, HEAD"); + return true; + } + const authResult = await authorizeScopedGatewayHttpRequestOrReply({ + req, + res, + auth: opts.auth, + trustedProxies: opts.trustedProxies, + allowRealIpFallback: opts.allowRealIpFallback, + rateLimiter: opts.rateLimiter, + operatorMethod: "users.list", + resolveOperatorScopes: resolveSharedSecretHttpOperatorScopes, + }); + if (!authResult) { + return true; + } + const avatar = getProfileAvatar(profileId); + if (!avatar) { + sendJson(res, 404, { ok: false, error: { type: "not_found" } }); + return true; + } + const etag = formatUserProfileAvatarEtag(avatar.sha256, avatar.mime); + if (ifNoneMatchMatches(req.headers["if-none-match"], etag)) { + res.writeHead(304, { ETag: etag }); + res.end(); + return true; + } + res.writeHead(200, { + "Content-Type": avatar.mime, + "Content-Length": avatar.bytes.byteLength, + "Cache-Control": "private, max-age=0, must-revalidate", + ETag: etag, + }); + res.end(method === "HEAD" ? undefined : avatar.bytes); + return true; +} + +// RFC 9110 §13.1.2 weak comparison: wildcard, comma-separated lists, and W/ prefixes +// all revalidate; exact-string matching alone would miss proxy-normalized headers. +function ifNoneMatchMatches(header: string | string[] | undefined, etag: string): boolean { + const value = Array.isArray(header) ? header.join(",") : header; + if (!value) { + return false; + } + return value.split(",").some((candidate) => { + const tag = candidate.trim(); + return tag === "*" || tag === etag || (tag.startsWith("W/") && tag.slice(2) === etag); + }); +} diff --git a/src/state/user-profiles-schema.ts b/src/state/user-profiles-schema.ts new file mode 100644 index 000000000000..e84fb0e8c289 --- /dev/null +++ b/src/state/user-profiles-schema.ts @@ -0,0 +1,23 @@ +// Canonical additive schema for durable user profiles. Kept feature-local so +// ordinary shared-state opens do not create identity tables until they are used. +export const USER_PROFILES_SCHEMA_SQL = ` +CREATE TABLE IF NOT EXISTS user_profiles ( + id TEXT NOT NULL PRIMARY KEY, + display_name TEXT, + avatar BLOB, + avatar_mime TEXT, + avatar_sha256 TEXT, + merged_into TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS user_profile_emails ( + email TEXT NOT NULL PRIMARY KEY, + profile_id TEXT NOT NULL, + created_at INTEGER NOT NULL +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_user_profile_emails_profile_id + ON user_profile_emails(profile_id); +`; diff --git a/src/state/user-profiles.test.ts b/src/state/user-profiles.test.ts new file mode 100644 index 000000000000..7a49543b554e --- /dev/null +++ b/src/state/user-profiles.test.ts @@ -0,0 +1,220 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; +import { + closeOpenClawStateDatabaseForTest, + openOpenClawStateDatabase, +} from "./openclaw-state-db.js"; +import { + ensureProfileForEmail, + formatUserProfileAvatarEtag, + getProfileAvatar, + linkEmail, + listProfiles, + resolveUserProfileId, + setAvatar, + setDisplayName, +} from "./user-profiles.js"; + +const statePaths: string[] = []; + +function stateOptions() { + const directory = mkdtempSync(join(tmpdir(), "openclaw-user-profiles-")); + const path = join(directory, "openclaw.sqlite"); + statePaths.push(path); + return { path }; +} + +afterEach(() => { + vi.restoreAllMocks(); + closeOpenClawStateDatabaseForTest(); +}); + +describe("user profiles", () => { + it("lazily ensures and resolves lowercased email aliases idempotently", () => { + const options = stateOptions(); + expect(tableExists(openOpenClawStateDatabase(options).db, "user_profiles")).toBe(false); + + const first = ensureProfileForEmail(" Ada@Example.COM ", options); + const second = ensureProfileForEmail("ada@example.com", options); + + expect(tableExists(openOpenClawStateDatabase(options).db, "user_profiles")).toBe(true); + expect(second).toEqual(first); + expect(ensureProfileForEmail("ADA@example.com", options)).toEqual(first); + expect(listProfiles(options)).toEqual([ + expect.objectContaining({ id: first.id, emails: ["ada@example.com"] }), + ]); + }); + + it("moves aliases and leaves an aliasless source profile as a one-hop tombstone", () => { + const options = stateOptions(); + const source = ensureProfileForEmail("source@example.com", options); + const target = ensureProfileForEmail("target@example.com", options); + + const linked = linkEmail("source@example.com", target.id, options); + + expect(ensureProfileForEmail("source@example.com", options).id).toBe(target.id); + expect(linked).toMatchObject({ + id: target.id, + emails: ["source@example.com", "target@example.com"], + hasAvatar: false, + }); + expect(listProfiles(options)).toContainEqual( + expect.objectContaining({ id: source.id, mergedInto: target.id, emails: [] }), + ); + }); + + it("compresses tombstones so durable profile references resolve to the merge head", () => { + const options = stateOptions(); + const a = ensureProfileForEmail("a@example.com", options); + const b = ensureProfileForEmail("b@example.com", options); + const c = ensureProfileForEmail("c@example.com", options); + + linkEmail("a@example.com", b.id, options); + linkEmail("a@example.com", c.id, options); + linkEmail("b@example.com", c.id, options); + + expect(setDisplayName(a.id, "Durable A", options)).toMatchObject({ id: c.id }); + expect(resolveUserProfileId(a.id, options)).toBe(c.id); + expect(listProfiles(options)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: a.id, mergedInto: c.id }), + expect.objectContaining({ id: b.id, mergedInto: c.id }), + ]), + ); + }); + + it("resolves a tombstoned link target to its head without forming a cycle", () => { + const options = stateOptions(); + const a = ensureProfileForEmail("a@example.com", options); + const b = ensureProfileForEmail("b@example.com", options); + + linkEmail("a@example.com", b.id, options); + linkEmail("a@example.com", a.id, options); + + expect(ensureProfileForEmail("a@example.com", options).id).toBe(b.id); + expect(listProfiles(options)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: a.id, mergedInto: b.id }), + expect.objectContaining({ id: b.id, mergedInto: null }), + ]), + ); + }); + + it("updates display names", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail("ada@example.com", options); + + expect(setDisplayName(profile.id, "Ada Lovelace", options)).toMatchObject({ + id: profile.id, + displayName: "Ada Lovelace", + emails: ["ada@example.com"], + hasAvatar: false, + }); + }); + + it("updates all profiles whose aliases change", () => { + const options = stateOptions(); + const now = vi.spyOn(Date, "now"); + now.mockReturnValue(100); + const source = ensureProfileForEmail("source@example.com", options); + now.mockReturnValue(200); + const target = ensureProfileForEmail("target@example.com", options); + now.mockReturnValue(300); + linkEmail("source-alias@example.com", source.id, options); + + now.mockReturnValue(400); + const linked = linkEmail("source@example.com", target.id, options); + + expect(linked).toMatchObject({ + id: target.id, + updatedAt: 400, + emails: ["source@example.com", "target@example.com"], + }); + expect(listProfiles(options)).toContainEqual( + expect.objectContaining({ + id: source.id, + updatedAt: 400, + emails: ["source-alias@example.com"], + }), + ); + }); + + it("bounds generated display names to the protocol limit", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail(`${"a".repeat(300)}@example.com`, options); + + expect(profile.displayName).toHaveLength(256); + }); + + it("rejects oversized and unsupported avatar uploads", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail("ada@example.com", options); + + expect(setAvatar(profile.id, new Uint8Array(512 * 1024 + 1), "image/png", options)).toEqual({ + ok: false, + error: { code: "avatar_too_large", maxBytes: 512 * 1024 }, + }); + expect(setAvatar(profile.id, new Uint8Array([1]), "image/gif", options)).toEqual({ + ok: false, + error: { code: "unsupported_avatar_mime", mime: "image/gif" }, + }); + }); + + it("stores an allowlisted avatar", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail("ada@example.com", options); + + expect(setAvatar(profile.id, new Uint8Array([1, 2, 3]), "image/png", options)).toEqual({ + ok: true, + value: expect.objectContaining({ + id: profile.id, + avatarMime: "image/png", + emails: ["ada@example.com"], + hasAvatar: true, + }), + }); + expect(getProfileAvatar(profile.id, options)).toEqual({ + bytes: new Uint8Array([1, 2, 3]), + mime: "image/png", + sha256: "039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81", + updatedAt: expect.any(Number), + }); + expect(listProfiles(options)).toEqual([ + expect.objectContaining({ id: profile.id, hasAvatar: true }), + ]); + }); + + it("keeps distinct avatar ETags when updates share a millisecond", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail("ada@example.com", options); + vi.spyOn(Date, "now").mockReturnValue(100); + + expect(setAvatar(profile.id, new Uint8Array([1]), "image/png", options).ok).toBe(true); + const first = getProfileAvatar(profile.id, options); + expect(setAvatar(profile.id, new Uint8Array([2]), "image/png", options).ok).toBe(true); + const second = getProfileAvatar(profile.id, options); + + expect(first?.updatedAt).toBe(second?.updatedAt); + expect(formatUserProfileAvatarEtag(first?.sha256 ?? "", first?.mime ?? "image/png")).not.toBe( + formatUserProfileAvatarEtag(second?.sha256 ?? "", second?.mime ?? "image/png"), + ); + }); + + it("keeps distinct avatar ETags when MIME changes with identical bytes", () => { + const options = stateOptions(); + const profile = ensureProfileForEmail("ada@example.com", options); + const bytes = new Uint8Array([1, 2, 3]); + + expect(setAvatar(profile.id, bytes, "image/png", options).ok).toBe(true); + const png = getProfileAvatar(profile.id, options); + expect(setAvatar(profile.id, bytes, "image/webp", options).ok).toBe(true); + const webp = getProfileAvatar(profile.id, options); + + expect(formatUserProfileAvatarEtag(png?.sha256 ?? "", png?.mime ?? "image/png")).not.toBe( + formatUserProfileAvatarEtag(webp?.sha256 ?? "", webp?.mime ?? "image/png"), + ); + }); +}); diff --git a/src/state/user-profiles.ts b/src/state/user-profiles.ts new file mode 100644 index 000000000000..2f55881bc6dc --- /dev/null +++ b/src/state/user-profiles.ts @@ -0,0 +1,490 @@ +import { createHash } from "node:crypto"; +// Durable user profiles and mutable login-email aliases in the shared state DB. +import type { DatabaseSync } from "node:sqlite"; +import { err, ok, type Result } from "@openclaw/normalization-core/result"; +import { sql } from "kysely"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../infra/kysely-sync.js"; +import { generateSecureUuid } from "../infra/secure-random.js"; +import { runSqliteDeferredTransactionSync } from "../infra/sqlite-transaction.js"; +import { + openOpenClawStateDatabase, + runOpenClawStateWriteTransaction, + type OpenClawStateDatabaseOptions, +} from "./openclaw-state-db.js"; +import { USER_PROFILES_SCHEMA_SQL } from "./user-profiles-schema.js"; + +const MAX_USER_PROFILE_AVATAR_BYTES = 512 * 1024; +const USER_PROFILE_AVATAR_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const; + +type UserProfileAvatarMime = (typeof USER_PROFILE_AVATAR_MIME_TYPES)[number]; + +type UserProfile = { + id: string; + displayName: string | null; + avatarMime: UserProfileAvatarMime | null; + mergedInto: string | null; + createdAt: number; + updatedAt: number; +}; + +type UserProfileListItem = UserProfile & { + emails: string[]; + hasAvatar: boolean; +}; + +type UserProfileAvatar = { + bytes: Uint8Array; + mime: UserProfileAvatarMime; + sha256: string; + updatedAt: number; +}; + +type UserProfileAvatarError = + | { code: "avatar_too_large"; maxBytes: number } + | { code: "unsupported_avatar_mime"; mime: string }; + +export function formatUserProfileAvatarEtag(sha256: string, mime: UserProfileAvatarMime): string { + return `"${sha256}-${mime.slice("image/".length)}"`; +} + +export class UserProfileNotFoundError extends Error { + constructor(profileId: string) { + super(`user profile not found: ${profileId}`); + this.name = "UserProfileNotFoundError"; + } +} + +type UserProfilesDatabase = { + user_profiles: { + id: string; + display_name: string | null; + avatar: Uint8Array | null; + avatar_mime: string | null; + avatar_sha256: string | null; + merged_into: string | null; + created_at: number; + updated_at: number; + }; + user_profile_emails: { + email: string; + profile_id: string; + created_at: number; + }; +}; + +type UserProfileRow = UserProfilesDatabase["user_profiles"]; +type UserProfileListRow = Pick< + UserProfileRow, + "id" | "display_name" | "avatar_mime" | "merged_into" | "created_at" | "updated_at" +> & { + has_avatar: unknown; +}; + +const ensuredDatabases = new WeakSet(); +const MAX_USER_PROFILE_DISPLAY_NAME_LENGTH = 256; + +function profileDb(db: DatabaseSync) { + return getNodeSqliteKysely(db); +} + +function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void { + const database = openOpenClawStateDatabase(options); + if (ensuredDatabases.has(database.db)) { + return; + } + runOpenClawStateWriteTransaction( + ({ db }) => { + db.exec(USER_PROFILES_SCHEMA_SQL); + }, + options, + { operationLabel: "user-profiles.schema.ensure" }, + ); + // Mark ensured only after the transaction commits; a rolled-back ensure must + // retry the DDL on the next call instead of failing "no such table" forever. + ensuredDatabases.add(database.db); +} + +function normalizeEmail(email: string): string { + const normalized = email.trim().toLowerCase(); + if (!normalized) { + throw new TypeError("email must not be empty"); + } + return normalized; +} + +function toAvatarMime(value: string | null): UserProfileAvatarMime | null { + return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime) + ? (value as UserProfileAvatarMime) + : null; +} + +function toUserProfile(row: UserProfileRow): UserProfile { + return { + id: row.id, + displayName: row.display_name, + avatarMime: toAvatarMime(row.avatar_mime), + mergedInto: row.merged_into, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function toUserProfileListItem(row: UserProfileListRow, emails: string[]): UserProfileListItem { + return { + id: row.id, + displayName: row.display_name, + avatarMime: toAvatarMime(row.avatar_mime), + mergedInto: row.merged_into, + createdAt: row.created_at, + updatedAt: row.updated_at, + emails, + hasAvatar: row.has_avatar === 1, + }; +} + +function hasAvatarColumn() { + return sql`CASE WHEN avatar IS NULL THEN 0 ELSE 1 END`.as("has_avatar"); +} + +function selectUserProfileListItemById(db: DatabaseSync, profileId: string): UserProfileListItem { + const kysely = profileDb(db); + const profile = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("user_profiles") + .select([ + "id", + "display_name", + "avatar_mime", + "merged_into", + "created_at", + "updated_at", + hasAvatarColumn(), + ]) + .where("id", "=", profileId), + ); + if (!profile) { + throw new UserProfileNotFoundError(profileId); + } + const emails = executeSqliteQuerySync( + db, + kysely + .selectFrom("user_profile_emails") + .select("email") + .where("profile_id", "=", profileId) + .orderBy("email", "asc"), + ).rows; + return toUserProfileListItem( + profile, + emails.map((alias) => alias.email), + ); +} + +function selectProfileById(db: DatabaseSync, profileId: string): UserProfileRow | undefined { + return executeSqliteQueryTakeFirstSync( + db, + profileDb(db).selectFrom("user_profiles").selectAll().where("id", "=", profileId), + ); +} + +function selectResolvedProfileById( + db: DatabaseSync, + profileId: string, +): UserProfileRow | undefined { + const profile = selectProfileById(db, profileId); + if (!profile?.merged_into) { + return profile; + } + // Every merge re-points aliases and tombstones targeting its source, so this + // one hop preserves durable references while the stored chain stays depth one. + return selectProfileById(db, profile.merged_into) ?? profile; +} + +function requireResolvedProfileById(db: DatabaseSync, profileId: string): UserProfileRow { + const profile = selectResolvedProfileById(db, profileId); + if (!profile) { + throw new UserProfileNotFoundError(profileId); + } + return profile; +} + +/** Resolves a durable profile reference to its current one-hop merge head. */ +export function resolveUserProfileId( + profileId: string, + options: OpenClawStateDatabaseOptions = {}, +): string | undefined { + ensureUserProfilesSchema(options); + const { db } = openOpenClawStateDatabase(options); + return selectResolvedProfileById(db, profileId)?.id; +} + +/** Reads a profile's protocol-facing representation through its merge head. */ +export function getUserProfileListItem( + profileId: string, + options: OpenClawStateDatabaseOptions = {}, +): UserProfileListItem { + ensureUserProfilesSchema(options); + const { db } = openOpenClawStateDatabase(options); + return selectUserProfileListItemById(db, requireResolvedProfileById(db, profileId).id); +} + +/** Resolves an email alias or atomically creates its first durable profile. */ +export function ensureProfileForEmail( + email: string, + options: OpenClawStateDatabaseOptions = {}, +): UserProfile { + const normalizedEmail = normalizeEmail(email); + const profileId = generateSecureUuid(); + const now = Date.now(); + const displayName = (normalizedEmail.split("@", 1)[0] || normalizedEmail).slice( + 0, + MAX_USER_PROFILE_DISPLAY_NAME_LENGTH, + ); + ensureUserProfilesSchema(options); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const kysely = profileDb(db); + const existingAlias = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("user_profile_emails") + .select("profile_id") + .where("email", "=", normalizedEmail), + ); + if (existingAlias) { + return toUserProfile(requireResolvedProfileById(db, existingAlias.profile_id)); + } + const row: UserProfileRow = { + id: profileId, + display_name: displayName, + avatar: null, + avatar_mime: null, + avatar_sha256: null, + merged_into: null, + created_at: now, + updated_at: now, + }; + executeSqliteQuerySync(db, kysely.insertInto("user_profiles").values(row)); + executeSqliteQuerySync( + db, + kysely.insertInto("user_profile_emails").values({ + email: normalizedEmail, + profile_id: profileId, + created_at: now, + }), + ); + return toUserProfile(row); + }, + options, + { operationLabel: "user-profiles.ensure" }, + ); +} + +/** Links an email to a profile and retains an aliasless prior profile as a merge tombstone. */ +export function linkEmail( + email: string, + targetProfileId: string, + options: OpenClawStateDatabaseOptions = {}, +): UserProfileListItem { + const normalizedEmail = normalizeEmail(email); + const now = Date.now(); + ensureUserProfilesSchema(options); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const kysely = profileDb(db); + const target = requireResolvedProfileById(db, targetProfileId); + const existingAlias = executeSqliteQueryTakeFirstSync( + db, + kysely + .selectFrom("user_profile_emails") + .select("profile_id") + .where("email", "=", normalizedEmail), + ); + if (!existingAlias) { + executeSqliteQuerySync( + db, + kysely.insertInto("user_profile_emails").values({ + email: normalizedEmail, + profile_id: target.id, + created_at: now, + }), + ); + executeSqliteQuerySync( + db, + kysely.updateTable("user_profiles").set({ updated_at: now }).where("id", "=", target.id), + ); + return selectUserProfileListItemById(db, target.id); + } + if (existingAlias.profile_id === target.id) { + return selectUserProfileListItemById(db, target.id); + } + executeSqliteQuerySync( + db, + kysely + .updateTable("user_profile_emails") + .set({ profile_id: target.id }) + .where("email", "=", normalizedEmail), + ); + const remainingAliases = executeSqliteQuerySync( + db, + kysely + .selectFrom("user_profile_emails") + .select("email") + .where("profile_id", "=", existingAlias.profile_id), + ).rows; + executeSqliteQuerySync( + db, + kysely.updateTable("user_profiles").set({ updated_at: now }).where("id", "=", target.id), + ); + if (remainingAliases.length === 0) { + executeSqliteQuerySync( + db, + kysely + .updateTable("user_profiles") + .set({ merged_into: target.id, updated_at: now }) + .where("id", "=", existingAlias.profile_id), + ); + executeSqliteQuerySync( + db, + kysely + .updateTable("user_profiles") + .set({ merged_into: target.id, updated_at: now }) + .where("merged_into", "=", existingAlias.profile_id), + ); + } else { + executeSqliteQuerySync( + db, + kysely + .updateTable("user_profiles") + .set({ updated_at: now }) + .where("id", "=", existingAlias.profile_id), + ); + } + return selectUserProfileListItemById(db, target.id); + }, + options, + { operationLabel: "user-profiles.link-email" }, + ); +} + +export function setDisplayName( + profileId: string, + name: string | null, + options: OpenClawStateDatabaseOptions = {}, +): UserProfileListItem { + const now = Date.now(); + ensureUserProfilesSchema(options); + return runOpenClawStateWriteTransaction( + ({ db }) => { + const profile = requireResolvedProfileById(db, profileId); + executeSqliteQuerySync( + db, + profileDb(db) + .updateTable("user_profiles") + .set({ display_name: name, updated_at: now }) + .where("id", "=", profile.id), + ); + return selectUserProfileListItemById(db, profile.id); + }, + options, + { operationLabel: "user-profiles.set-display-name" }, + ); +} + +/** Stores a bounded, allowlisted avatar without ever leaving the write transaction async. */ +export function setAvatar( + profileId: string, + bytes: Uint8Array, + mime: string, + options: OpenClawStateDatabaseOptions = {}, +): Result { + if (bytes.byteLength > MAX_USER_PROFILE_AVATAR_BYTES) { + return err({ code: "avatar_too_large", maxBytes: MAX_USER_PROFILE_AVATAR_BYTES }); + } + if (!USER_PROFILE_AVATAR_MIME_TYPES.includes(mime as UserProfileAvatarMime)) { + return err({ code: "unsupported_avatar_mime", mime }); + } + const now = Date.now(); + ensureUserProfilesSchema(options); + const value = runOpenClawStateWriteTransaction( + ({ db }) => { + const profile = requireResolvedProfileById(db, profileId); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + executeSqliteQuerySync( + db, + profileDb(db) + .updateTable("user_profiles") + .set({ avatar: bytes, avatar_mime: mime, avatar_sha256: sha256, updated_at: now }) + .where("id", "=", profile.id), + ); + return selectUserProfileListItemById(db, profile.id); + }, + options, + { operationLabel: "user-profiles.set-avatar" }, + ); + return ok(value); +} + +export function getProfileAvatar( + profileId: string, + options: OpenClawStateDatabaseOptions = {}, +): UserProfileAvatar | undefined { + ensureUserProfilesSchema(options); + const { db } = openOpenClawStateDatabase(options); + const profile = selectResolvedProfileById(db, profileId); + if (!profile?.avatar || !profile.avatar_mime || !profile.avatar_sha256) { + return undefined; + } + const mime = toAvatarMime(profile.avatar_mime); + return mime + ? { bytes: profile.avatar, mime, sha256: profile.avatar_sha256, updatedAt: profile.updated_at } + : undefined; +} + +export function listProfiles(options: OpenClawStateDatabaseOptions = {}): UserProfileListItem[] { + ensureUserProfilesSchema(options); + const database = openOpenClawStateDatabase(options); + return runSqliteDeferredTransactionSync( + database.db, + () => { + const kysely = profileDb(database.db); + const profiles = executeSqliteQuerySync( + database.db, + kysely + .selectFrom("user_profiles") + .select([ + "id", + "display_name", + "avatar_mime", + "merged_into", + "created_at", + "updated_at", + hasAvatarColumn(), + ]) + .orderBy("created_at", "asc") + .orderBy("id", "asc"), + ).rows; + const emails = executeSqliteQuerySync( + database.db, + kysely + .selectFrom("user_profile_emails") + .select(["profile_id", "email"]) + .orderBy("email", "asc"), + ).rows; + const emailsByProfile = new Map(); + for (const email of emails) { + const list = emailsByProfile.get(email.profile_id) ?? []; + list.push(email.email); + emailsByProfile.set(email.profile_id, list); + } + return profiles.map((profile) => + toUserProfileListItem(profile, emailsByProfile.get(profile.id) ?? []), + ); + }, + { databaseLabel: database.path, operationLabel: "user-profiles.list" }, + ); +}