feat(gateway): durable user profiles with email aliases and avatars (#111224)

* 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
This commit is contained in:
Peter Steinberger
2026-07-19 00:58:43 -07:00
committed by GitHub
parent 58452de711
commit a8b7290f34
22 changed files with 1825 additions and 0 deletions
@@ -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"),
+42
View File
@@ -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<AuditActivityListPara
AuditActivityListParamsSchema,
);
export const validateAuditListParams = lazyCompile(AuditListParamsSchema);
export const validateUsersListParams = lazyCompile(UsersListParamsSchema);
export const validateUsersSelfParams = lazyCompile(UsersSelfParamsSchema);
export const validateUsersSelfResult = lazyCompile(UsersSelfResultSchema);
export const validateUsersLinkEmailParams = lazyCompile(UsersLinkEmailParamsSchema);
export const validateUsersLinkEmailResult = lazyCompile(UsersLinkEmailResultSchema);
export const validateUsersSetDisplayNameParams = lazyCompile(UsersSetDisplayNameParamsSchema);
export const validateUsersSetDisplayNameResult = lazyCompile(UsersSetDisplayNameResultSchema);
export const validateUsersSetAvatarParams = lazyCompile(UsersSetAvatarParamsSchema);
export const validateUsersSetAvatarResult = lazyCompile(UsersSetAvatarResultSchema);
export const validateAgentIdentityParams = lazyCompile(AgentIdentityParamsSchema);
export const validateAgentWaitParams = lazyCompile(AgentWaitParamsSchema);
export const validateWakeParams = lazyCompile(WakeParamsSchema);
@@ -1152,6 +1172,17 @@ export {
AuditEventSchema,
AuditListParamsSchema,
AuditListResultSchema,
UserProfileSchema,
UsersLinkEmailParamsSchema,
UsersLinkEmailResultSchema,
UsersListParamsSchema,
UsersListResultSchema,
UsersSelfParamsSchema,
UsersSelfResultSchema,
UsersSetAvatarParamsSchema,
UsersSetAvatarResultSchema,
UsersSetDisplayNameParamsSchema,
UsersSetDisplayNameResultSchema,
TaskSuggestionSchema,
TaskSuggestionEventSchema,
TaskSuggestionResolutionSchema,
@@ -1746,6 +1777,17 @@ export type {
AuditEvent,
AuditListParams,
AuditListResult,
UserProfile,
UsersLinkEmailParams,
UsersLinkEmailResult,
UsersListParams,
UsersListResult,
UsersSelfParams,
UsersSelfResult,
UsersSetAvatarParams,
UsersSetAvatarResult,
UsersSetDisplayNameParams,
UsersSetDisplayNameResult,
TaskSuggestion,
TaskSuggestionEvent,
TaskSuggestionResolution,
+1
View File
@@ -13,6 +13,7 @@ export * from "./schema/approvals.js";
export * from "./schema/audit-activity.js";
export * from "./schema/audit.js";
export * from "./schema/board.js";
export * from "./schema/users.js";
export * from "./schema/channels.js";
export * from "./schema/talk-marks.js";
export * from "./schema/commands.js";
@@ -0,0 +1,61 @@
// Gateway Protocol schemas for durable user profiles and email aliases.
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
const UserProfileIdSchema = Type.String({ minLength: 1, maxLength: 128 });
const UserProfileDisplayNameSchema = Type.String({ maxLength: 256 });
export const UserProfileAvatarMimeSchema = Type.Union([
Type.Literal("image/png"),
Type.Literal("image/jpeg"),
Type.Literal("image/webp"),
]);
export const UserProfileSchema = closedObject({
id: UserProfileIdSchema,
displayName: Type.Union([UserProfileDisplayNameSchema, Type.Null()]),
avatarMime: Type.Union([UserProfileAvatarMimeSchema, Type.Null()]),
mergedInto: Type.Union([UserProfileIdSchema, Type.Null()]),
createdAt: Type.Integer({ minimum: 0 }),
updatedAt: Type.Integer({ minimum: 0 }),
emails: Type.Array(NonEmptyString),
hasAvatar: Type.Boolean(),
});
export const UsersListParamsSchema = closedObject({});
export const UsersListResultSchema = closedObject({ profiles: Type.Array(UserProfileSchema) });
export const UsersSelfParamsSchema = closedObject({});
export const UsersSelfResultSchema = closedObject({ profile: UserProfileSchema });
export const UsersLinkEmailParamsSchema = closedObject({
email: Type.String({ minLength: 1, maxLength: 320 }),
targetProfileId: UserProfileIdSchema,
});
export const UsersLinkEmailResultSchema = closedObject({ profile: UserProfileSchema });
export const UsersSetDisplayNameParamsSchema = closedObject({
profileId: UserProfileIdSchema,
displayName: Type.Union([UserProfileDisplayNameSchema, Type.Null()]),
});
export const UsersSetDisplayNameResultSchema = closedObject({ profile: UserProfileSchema });
export const UsersSetAvatarParamsSchema = closedObject({
profileId: UserProfileIdSchema,
mime: UserProfileAvatarMimeSchema,
avatarBase64: Type.String({ minLength: 1, maxLength: 700_000 }),
});
export const UsersSetAvatarResultSchema = closedObject({ profile: UserProfileSchema });
export type UserProfile = Static<typeof UserProfileSchema>;
export type UsersListParams = Static<typeof UsersListParamsSchema>;
export type UsersListResult = Static<typeof UsersListResultSchema>;
export type UsersSelfParams = Static<typeof UsersSelfParamsSchema>;
export type UsersSelfResult = Static<typeof UsersSelfResultSchema>;
export type UsersLinkEmailParams = Static<typeof UsersLinkEmailParamsSchema>;
export type UsersLinkEmailResult = Static<typeof UsersLinkEmailResultSchema>;
export type UsersSetDisplayNameParams = Static<typeof UsersSetDisplayNameParamsSchema>;
export type UsersSetDisplayNameResult = Static<typeof UsersSetDisplayNameResultSchema>;
export type UsersSetAvatarParams = Static<typeof UsersSetAvatarParamsSchema>;
export type UsersSetAvatarResult = Static<typeof UsersSetAvatarResultSchema>;
+1
View File
@@ -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",
+5
View File
@@ -154,6 +154,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
loadModule: () => import("../devices-cli.js"),
exportName: "registerDevicesCli",
},
{
commandNames: ["users"],
loadModule: () => import("../users-cli.js"),
exportName: "registerUsersCli",
},
{
commandNames: ["node"],
loadModule: () => import("../node-cli.js"),
+6
View File
@@ -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",
+74
View File
@@ -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");
});
});
+77
View File
@@ -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 <url>", "Gateway WebSocket URL (defaults to gateway.remote.url when configured)")
.option("--token <token>", "Gateway token (if required)")
.option("--timeout <ms>", "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 <email>")
.description("Link an email alias to a user profile")
.requiredOption("--to <profileId>", "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 };
+5
View File
@@ -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"]],
+5
View File
@@ -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" },
+23
View File
@@ -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<ReadonlySet<string>>
@@ -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",
@@ -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<typeof handleGatewayRequest>[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 });
});
});
+14
View File
@@ -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,
+232
View File
@@ -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");
});
});
+181
View File
@@ -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<typeof formatValidationErrors>[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));
}
},
};
+13
View File
@@ -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;
}
}
+148
View File
@@ -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<typeof import("./http-utils.js")>()),
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<string, string> = {}) {
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"' });
},
);
});
+79
View File
@@ -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<boolean> {
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);
});
}
+23
View File
@@ -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);
`;
+220
View File
@@ -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"),
);
});
});
+490
View File
@@ -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<DatabaseSync>();
const MAX_USER_PROFILE_DISPLAY_NAME_LENGTH = 256;
function profileDb(db: DatabaseSync) {
return getNodeSqliteKysely<UserProfilesDatabase>(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<UserProfileListItem, UserProfileAvatarError> {
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<string, string[]>();
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" },
);
}