mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
58452de711
commit
a8b7290f34
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"' });
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
`;
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user