fix: adopt Tailscale names and avatars into user profiles (#119479)

* fix(gateway): adopt Tailscale profile identities

* fix(gateway): normalize Tailscale identity subjects

* fix(gateway): preserve Tailscale identity provenance

* fix(gateway): detach Tailscale avatar adoption
This commit is contained in:
Peter Steinberger
2026-08-04 23:09:06 -07:00
committed by GitHub
parent cb3890b921
commit dca3ed16de
17 changed files with 1009 additions and 57 deletions
@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
maybeRepairPluginOpenClawHostLinks: vi.fn(),
maybeRepairLegacyOAuthSidecarProfiles: vi.fn(),
migrateLegacyOnboardingRecommendationsScope: vi.fn(),
migrateLegacyTailscaleProfileIdentities: vi.fn(),
maybeMigrateAuthProfileJsonStoresToSqlite: vi.fn(),
maybeRepairOpenAICodexAuthConfig: vi.fn(),
maybeRepairOpenPolicyAllowFrom: vi.fn(),
@@ -51,6 +52,10 @@ vi.mock("../../infra/state-migrations.onboarding-recommendations.js", () => ({
migrateLegacyOnboardingRecommendationsScope: mocks.migrateLegacyOnboardingRecommendationsScope,
}));
vi.mock("../../state/user-profiles-tailscale-migration.js", () => ({
migrateLegacyTailscaleProfileIdentities: mocks.migrateLegacyTailscaleProfileIdentities,
}));
vi.mock("../doctor-auth-flat-profiles.js", () => ({
collectOpenAICodexAuthProfileStoreIdMap: mocks.collectOpenAICodexAuthProfileStoreIdMap,
maybeMigrateAuthProfileJsonStoresToSqlite: mocks.maybeMigrateAuthProfileJsonStoresToSqlite,
@@ -259,6 +264,7 @@ describe("doctor repair sequencing", () => {
changes: [],
warnings: [],
});
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValue({ changes: [], warnings: [] });
mocks.collectOpenAICodexAuthProfileStoreIdMap.mockReturnValue(new Map());
mocks.maybeMigrateAuthProfileJsonStoresToSqlite.mockResolvedValue({
detected: [],
@@ -327,6 +333,25 @@ describe("doctor repair sequencing", () => {
expect(result.warningNotes).toContain("Migration warning.");
});
it("runs the doctor-only Tailscale profile identity migration", async () => {
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
const candidate = {} as OpenClawConfig;
mocks.migrateLegacyTailscaleProfileIdentities.mockReturnValue({
changes: ["Migrated Tailscale profile identity."],
warnings: ["Tailscale identity conflict."],
});
const result = await runDoctorRepairSequence({
state: { cfg: candidate, candidate, pendingChanges: false, fixHints: [] },
doctorFixCommand: "openclaw doctor --fix",
env,
});
expect(mocks.migrateLegacyTailscaleProfileIdentities).toHaveBeenCalledWith({ env });
expect(result.changeNotes).toContain("Migrated Tailscale profile identity.");
expect(result.warningNotes).toContain("Tailscale identity conflict.");
});
it("retains the exact auth profile map after import for later session-owner repair", async () => {
const env = { OPENCLAW_STATE_DIR: "/tmp/openclaw-doctor-test" };
const candidate = {} as OpenClawConfig;
+2
View File
@@ -5,6 +5,7 @@ import {
materializePluginAutoEnableCandidates,
} from "../../config/plugin-auto-enable.js";
import { migrateLegacyOnboardingRecommendationsScope } from "../../infra/state-migrations.onboarding-recommendations.js";
import { migrateLegacyTailscaleProfileIdentities } from "../../state/user-profiles-tailscale-migration.js";
import {
collectOpenAICodexAuthProfileStoreIdMap,
maybeMigrateAuthProfileJsonStoresToSqlite,
@@ -230,6 +231,7 @@ export async function runDoctorRepairSequence(params: {
await applyRepairStages([maybeRepairLegacyToolsBySenderKeys, maybeRepairExecSafeBinProfiles]);
appendRepairNotes(await migrateLegacySkillWorkshopProposals({ config: state.candidate, env }));
appendRepairNotes(migrateLegacyTailscaleProfileIdentities({ env }));
appendRepairNotes(await cleanupLegacyPluginDependencyState({ env }));
appendRepairNotes(
migrateLegacyOnboardingRecommendationsScope({
+13 -7
View File
@@ -55,15 +55,16 @@ function createTailscaleForwardedReq(): TailscaleForwardedRequest {
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter",
"tailscale-user-login": "peter@github",
"tailscale-user-name": "Peter",
"tailscale-user-profile-pic": "https://avatars.example.test/peter.png",
"sec-fetch-site": "same-origin",
},
} as unknown as TailscaleForwardedRequest;
}
function createTailscaleWhois() {
return async () => ({ login: "peter", name: "Peter" });
return async () => ({ login: "peter@github", name: "Peter" });
}
function createAvatarBrowserOriginPolicy(
@@ -499,7 +500,12 @@ describe("gateway auth", () => {
expect(res.ok).toBe(true);
expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter");
expect(res.user).toBe("peter@github");
expect(res.tailscaleIdentity).toEqual({
login: "peter@github",
name: "Peter",
profilePic: "https://avatars.example.test/peter.png",
});
});
it("allows an origin-less same-origin image through the profile avatar surface", async () => {
@@ -514,7 +520,7 @@ describe("gateway auth", () => {
browserOriginPolicy: createAvatarBrowserOriginPolicy(req),
});
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter" });
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter@github" });
expect(limiter.check).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
expect(limiter.reset).toHaveBeenCalledWith("127.0.0.1", "shared-secret");
});
@@ -569,7 +575,7 @@ describe("gateway auth", () => {
browserOriginPolicy: createAvatarBrowserOriginPolicy(req, ["https://control.example.com"]),
});
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter" });
expect(res).toMatchObject({ ok: true, method: "tailscale", user: "peter@github" });
expect(tailscaleWhois).toHaveBeenCalledOnce();
});
@@ -716,14 +722,14 @@ describe("gateway auth", () => {
it("enables tailscale header auth on the profile avatar HTTP wrapper", async () => {
await expectTailscaleHeaderAuthResult({
authorize: authorizeUserProfileAvatarHttpGatewayConnect,
expected: { ok: true, method: "tailscale", user: "peter" },
expected: { ok: true, method: "tailscale", user: "peter@github" },
});
});
it("enables tailscale header auth on ws control-ui auth wrapper", async () => {
await expectTailscaleHeaderAuthResult({
authorize: authorizeWsControlUiGatewayConnect,
expected: { ok: true, method: "tailscale", user: "peter" },
expected: { ok: true, method: "tailscale", user: "peter@github" },
});
});
+6 -3
View File
@@ -46,6 +46,8 @@ export type GatewayAuthResult = {
| "bootstrap-token"
| "trusted-proxy";
user?: string;
/** Full verified Tailscale identity; present only after header + WhoIs agreement. */
tailscaleIdentity?: VerifiedTailscaleIdentity;
reason?: string;
/** Present when the request was blocked by the rate limiter. */
rateLimited?: boolean;
@@ -90,7 +92,7 @@ type AuthorizeGatewayConnectParams = {
};
};
type TailscaleUser = {
type VerifiedTailscaleIdentity = {
login: string;
name: string;
profilePic?: string;
@@ -152,7 +154,7 @@ function resolveTailscaleClientIp(req?: IncomingMessage): string | undefined {
});
}
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
function getTailscaleUser(req?: IncomingMessage): VerifiedTailscaleIdentity | null {
if (!req) {
return null;
}
@@ -191,7 +193,7 @@ function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
async function resolveVerifiedTailscaleUser(params: {
req?: IncomingMessage;
tailscaleWhois: TailscaleWhoisLookup;
}): Promise<{ ok: true; user: TailscaleUser } | { ok: false; reason: string }> {
}): Promise<{ ok: true; user: VerifiedTailscaleIdentity } | { ok: false; reason: string }> {
const { req, tailscaleWhois } = params;
const tailscaleUser = getTailscaleUser(req);
if (!tailscaleUser) {
@@ -571,6 +573,7 @@ async function authorizeGatewayConnectCore(
ok: true,
method: "tailscale",
user: tailscaleCheck.user.login,
tailscaleIdentity: tailscaleCheck.user,
};
}
}
@@ -68,6 +68,8 @@ export type GatewayClient = {
/** Client id verified against the server-approved device pairing record. */
pairedClientId?: string;
authenticatedUserId?: string;
/** Verified Tailscale provider identity; generic proxy identities must not infer this. */
authenticatedUserIsTailscaleProvider?: boolean;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
+77
View File
@@ -91,6 +91,58 @@ describe("users gateway methods", () => {
expect(getUserProfileListItem).toHaveBeenNthCalledWith(2, profile.id);
});
it("uses the connect-time provider profile without recreating an email alias", async () => {
const providerClient = {
authenticatedUserId: "ada@github",
authenticatedUserIsTailscaleProvider: true,
authenticatedUserProfile: {
profileId: profile.id,
displayName: "Ada",
hasAvatar: false,
updatedAt: 1,
},
connect: { scopes: ["operator.write"] },
};
resolveUserProfileId.mockReturnValue(profile.id);
getUserProfileListItem.mockReturnValue({ ...profile, emails: [] });
const respond = await runUsersHandler("users.self", {}, providerClient);
expect(respond).toHaveBeenCalledWith(true, { profile: { ...profile, emails: [] } });
expect(ensureProfileForEmail).not.toHaveBeenCalled();
});
it("keeps generic proxy identities on the legacy profile fallback", async () => {
const proxyClient = {
authenticatedUserId: "ada@github",
connect: { scopes: ["operator.write"] },
};
ensureProfileForEmail.mockReturnValue({ id: profile.id });
getUserProfileListItem.mockReturnValue(profile);
const respond = await runUsersHandler("users.self", {}, proxyClient);
expect(respond).toHaveBeenCalledWith(true, { profile });
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@github");
});
it("does not recreate a failed Tailscale provider snapshot as an email alias", async () => {
const tailscaleClient = {
authenticatedUserId: "ada@github",
authenticatedUserIsTailscaleProvider: true,
connect: { scopes: ["operator.write"] },
};
const respond = await runUsersHandler("users.self", {}, tailscaleClient);
expect(respond).toHaveBeenCalledWith(
false,
undefined,
expect.objectContaining({ message: "authenticated user profile is unavailable" }),
);
expect(ensureProfileForEmail).not.toHaveBeenCalled();
});
it("rejects users.self without an authenticated user", async () => {
expect(
await runUsersHandler("users.self", {}, { connect: { scopes: ["operator.write"] } }),
@@ -215,6 +267,31 @@ describe("users gateway methods", () => {
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
});
it("authorizes provider-owned profile edits from the connect-time profile id", async () => {
const providerClient = {
authenticatedUserId: "ada@github",
authenticatedUserIsTailscaleProvider: true,
authenticatedUserProfile: {
profileId: profile.id,
displayName: "Ada",
hasAvatar: false,
updatedAt: 1,
},
connect: { scopes: ["operator.write"] },
};
resolveUserProfileId.mockReturnValue(profile.id);
setDisplayName.mockReturnValue(profile);
expect(
await runUsersHandler(
"users.setDisplayName",
{ profileId: profile.id, displayName: "Ada Lovelace" },
providerClient,
),
).toHaveBeenCalledWith(true, { profile });
expect(ensureProfileForEmail).not.toHaveBeenCalled();
});
it("denies an identified write caller changing another profile's avatar", async () => {
ensureProfileForEmail.mockReturnValue(profile);
resolveUserProfileId.mockReturnValue("profile-2");
+33 -6
View File
@@ -49,6 +49,24 @@ function profileError(error: unknown) {
return errorShape(ErrorCodes.UNAVAILABLE, formatErrorMessage(error));
}
function resolveAuthenticatedProfileId(
client: GatewayRequestHandlerOptions["client"],
): string | undefined {
if (client?.authenticatedUserProfile?.profileId) {
return resolveUserProfileId(client.authenticatedUserProfile.profileId);
}
const authenticatedUserId = client?.authenticatedUserId;
if (!authenticatedUserId) {
return undefined;
}
// A failed Tailscale profile snapshot must not recreate its provider login
// through the legacy email resolver on a later self-profile request.
if (client.authenticatedUserIsTailscaleProvider) {
return undefined;
}
return ensureProfileForEmail(authenticatedUserId).id;
}
function canMutateProfile(
client: GatewayRequestHandlerOptions["client"],
profileId: string,
@@ -56,10 +74,11 @@ function canMutateProfile(
if (client?.connect.scopes?.includes(ADMIN_SCOPE)) {
return true;
}
const authenticatedUserId = client?.authenticatedUserId;
return authenticatedUserId
? ensureProfileForEmail(authenticatedUserId).id === resolveUserProfileId(profileId)
: false;
const authenticatedProfileId = resolveAuthenticatedProfileId(client);
return (
authenticatedProfileId !== undefined &&
authenticatedProfileId === resolveUserProfileId(profileId)
);
}
function requireProfileMutationAccess(
@@ -102,8 +121,16 @@ export const usersHandlers: GatewayRequestHandlers = {
return;
}
try {
const profile = ensureProfileForEmail(client.authenticatedUserId);
respond(true, { profile: getUserProfileListItem(profile.id) });
const profileId = resolveAuthenticatedProfileId(client);
if (!profileId) {
respond(
false,
undefined,
errorShape(ErrorCodes.UNAVAILABLE, "authenticated user profile is unavailable"),
);
return;
}
respond(true, { profile: getUserProfileListItem(profileId) });
} catch (error) {
respond(false, undefined, profileError(error));
}
@@ -19,7 +19,12 @@ import { loadVoiceWakeRoutingConfig } from "../../../infra/voicewake-routing.js"
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { resolveLocalNodeId } from "../../../node-host/local-id.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../skills/runtime/remote.js";
import { ensureProfileForEmail } from "../../../state/user-profiles.js";
import { classifyTailscaleLogin } from "../../../state/user-profiles-tailscale-login.js";
import {
adoptTailscaleProfileAvatar,
ensureProfileForEmail,
ensureProfileForTailscaleIdentity,
} from "../../../state/user-profiles.js";
import {
isBrowserCopilotClient,
isEphemeralGatewayClient,
@@ -42,6 +47,7 @@ import { formatUserProfileAvatarPath } from "../../user-profiles-http-path.js";
import { formatForLog, logWs } from "../../ws-log.js";
import { truncateCloseReason } from "../close-reason.js";
import { incrementPresenceVersion } from "../health-state.js";
import { broadcastPresenceSnapshot } from "../presence-events.js";
import type { GatewayWsClient } from "../ws-types.js";
import { sendGatewayHello } from "./connect-hello.js";
import { prepareGatewayNodeConnect } from "./connect-node-session.js";
@@ -175,6 +181,10 @@ export async function attachAuthenticatedGatewayConnect(
: connId
: undefined;
const authenticatedUserId = normalizeOptionalString(authResult.user);
const authenticatedUserIsTailscaleProvider = Boolean(
authResult.tailscaleIdentity &&
classifyTailscaleLogin(authResult.tailscaleIdentity.login).kind === "provider",
);
if (isClosed()) {
await releasePendingNodePairingCleanup();
@@ -188,8 +198,10 @@ export async function attachAuthenticatedGatewayConnect(
let authenticatedUserProfile: GatewayWsClient["authenticatedUserProfile"];
if (authenticatedUserId) {
try {
const profile = ensureProfileForEmail(authenticatedUserId);
// Profile metadata is a connect-time snapshot; edits become visible after reconnect.
const profile = authResult.tailscaleIdentity
? ensureProfileForTailscaleIdentity(authResult.tailscaleIdentity)
: ensureProfileForEmail(authenticatedUserId);
// User edits become visible after reconnect; detached provider-avatar adoption refreshes below.
authenticatedUserProfile = {
profileId: profile.id,
displayName: profile.displayName,
@@ -197,7 +209,7 @@ export async function attachAuthenticatedGatewayConnect(
updatedAt: profile.updatedAt,
};
} catch (error) {
// Profile storage must not block login; retain the legacy email-only identity on failure.
// Profile storage and best-effort provider metadata must never block login.
logWsControl.warn(
`user profile resolution failed conn=${connId} user=${formatForLog(authenticatedUserId)}: ${formatForLog(error)}`,
);
@@ -309,6 +321,7 @@ export async function attachAuthenticatedGatewayConnect(
sharedGatewaySessionGeneration: sessionSharedGatewaySessionGeneration,
presenceKey,
...(authenticatedUserId ? { authenticatedUserId } : {}),
...(authenticatedUserIsTailscaleProvider ? { authenticatedUserIsTailscaleProvider: true } : {}),
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
clientIp: reportedClientIp,
...(internal ? { internal } : {}),
@@ -441,6 +454,30 @@ export async function attachAuthenticatedGatewayConnect(
);
}
const buildAuthenticatedPresenceUser = () => {
if (!authenticatedUserId) {
return undefined;
}
if (!authenticatedUserProfile) {
return {
id: authenticatedUserId,
...(authenticatedUserIsTailscaleProvider ? {} : { email: authenticatedUserId }),
};
}
return {
id: authenticatedUserProfile.profileId,
...(authenticatedUserIsTailscaleProvider ? {} : { email: authenticatedUserId }),
...(authenticatedUserProfile.displayName
? { name: authenticatedUserProfile.displayName }
: {}),
// This authenticated route resolves the uploaded avatar first, then the
// gateway-side Gravatar proxy, so clients never need an email-hash URL.
// The revision changes when the profile avatar changes, so reconnecting
// viewers refetch instead of reusing a stale route response.
avatarUrl: `${formatUserProfileAvatarPath(authenticatedUserProfile.profileId)}?v=${authenticatedUserProfile.updatedAt}`,
};
};
if (presenceKey) {
upsertPresence(presenceKey, {
host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(),
@@ -454,25 +491,7 @@ export async function attachAuthenticatedGatewayConnect(
roles: [role],
scopes,
instanceId: role === "node" ? (device?.id ?? instanceId) : instanceId,
...(authenticatedUserId
? {
user: authenticatedUserProfile
? {
id: authenticatedUserProfile.profileId,
email: authenticatedUserId,
...(authenticatedUserProfile.displayName
? { name: authenticatedUserProfile.displayName }
: {}),
// This authenticated route resolves the uploaded avatar first, then the
// gateway-side Gravatar proxy, so clients never need an email-hash URL.
// The ?v=<updatedAt> revision changes when the profile (avatar) is
// updated, so a reconnecting viewer's <img> refetches instead of reusing
// a stale cached image for the unchanged route.
avatarUrl: `${formatUserProfileAvatarPath(authenticatedUserProfile.profileId)}?v=${authenticatedUserProfile.updatedAt}`,
}
: { id: authenticatedUserId, email: authenticatedUserId },
}
: {}),
...(authenticatedUserId ? { user: buildAuthenticatedPresenceUser() } : {}),
reason: "connect",
});
incrementPresenceVersion();
@@ -552,4 +571,36 @@ export async function attachAuthenticatedGatewayConnect(
}
await sendGatewayHello(context, state, pluginSurfaceUrls);
const tailscaleProfilePic = authResult.tailscaleIdentity?.profilePic;
const tailscaleProfileId = authenticatedUserProfile?.profileId;
if (tailscaleProfileId && !authenticatedUserProfile?.hasAvatar && tailscaleProfilePic) {
runDetachedConnectWork(
async () => {
const updated = await adoptTailscaleProfileAvatar(tailscaleProfileId, tailscaleProfilePic);
if (!updated.avatarMime) {
return;
}
authenticatedUserProfile = {
profileId: updated.id,
displayName: updated.displayName,
hasAvatar: true,
updatedAt: updated.updatedAt,
};
nextClient.authenticatedUserProfile = authenticatedUserProfile;
if (isClosed() || !presenceKey) {
return;
}
upsertPresence(presenceKey, { user: buildAuthenticatedPresenceUser() });
const requestContext = buildRequestContext();
broadcastPresenceSnapshot({
broadcast: requestContext.broadcast,
incrementPresenceVersion: requestContext.incrementPresenceVersion,
getHealthVersion: requestContext.getHealthVersion,
});
},
(error) =>
logGateway.warn(`Tailscale avatar adoption failed conn=${connId}: ${formatForLog(error)}`),
);
}
}
@@ -30,7 +30,9 @@ const {
getHealthVersionMock,
incrementPresenceVersionMock,
loadConfigMock,
adoptTailscaleProfileAvatarMock,
ensureProfileForEmailMock,
resolveConnectAuthStateMock,
upsertPresenceMock,
} = vi.hoisted(() => ({
buildGatewaySnapshotMock: vi.fn(() => ({
@@ -56,14 +58,27 @@ const {
},
},
})),
adoptTailscaleProfileAvatarMock: vi.fn(),
ensureProfileForEmailMock: vi.fn(),
resolveConnectAuthStateMock: vi.fn(),
upsertPresenceMock: vi.fn(),
}));
vi.mock("../../../state/user-profiles.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../state/user-profiles.js")>();
adoptTailscaleProfileAvatarMock.mockImplementation(actual.adoptTailscaleProfileAvatar);
ensureProfileForEmailMock.mockImplementation(actual.ensureProfileForEmail);
return { ...actual, ensureProfileForEmail: ensureProfileForEmailMock };
return {
...actual,
adoptTailscaleProfileAvatar: adoptTailscaleProfileAvatarMock,
ensureProfileForEmail: ensureProfileForEmailMock,
};
});
vi.mock("./auth-context.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./auth-context.js")>();
resolveConnectAuthStateMock.mockImplementation(actual.resolveConnectAuthState);
return { ...actual, resolveConnectAuthState: resolveConnectAuthStateMock };
});
vi.mock("../../../config/config.js", () => ({
@@ -711,6 +726,89 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
});
});
it("registers a verified profile before detached Tailscale avatar adoption completes", async () => {
await withOpenClawTestState({ label: "gateway-tailscale-avatar-detached" }, async () => {
let resolveAvatar:
| ((profile: {
id: string;
displayName: string | null;
avatarMime: "image/png" | "image/jpeg" | "image/webp" | null;
mergedInto: string | null;
createdAt: number;
updatedAt: number;
}) => void)
| undefined;
adoptTailscaleProfileAvatarMock.mockImplementationOnce(
async () =>
await new Promise((resolve) => {
resolveAvatar = resolve;
}),
);
resolveConnectAuthStateMock.mockResolvedValueOnce({
authResult: {
ok: true,
method: "tailscale",
user: "ada@github",
tailscaleIdentity: {
login: "ada@github",
name: "Ada Lovelace",
profilePic: "https://avatars.example.test/ada.png",
},
},
authOk: true,
authMethod: "tailscale",
sharedAuthOk: true,
sharedAuthProvided: false,
});
const harness = attachGatewayHarness({
connId: "conn-tailscale-avatar-detached",
connectNonce: "nonce-tailscale-avatar-detached",
});
harness.sendConnect("connect-tailscale-avatar-detached", {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "gateway-client",
version: "dev",
platform: "test",
mode: "backend",
},
role: "operator",
caps: [],
});
await waitForFast(() => {
expect(harness.client).toMatchObject({
authenticatedUserId: "ada@github",
authenticatedUserIsTailscaleProvider: true,
authenticatedUserProfile: { displayName: "Ada Lovelace", hasAvatar: false },
});
expect(adoptTailscaleProfileAvatarMock).toHaveBeenCalledOnce();
});
expect(harness.socketSend).toHaveBeenCalled();
const profile = (
harness.client as {
authenticatedUserProfile: { profileId: string; displayName: string; updatedAt: number };
}
).authenticatedUserProfile;
resolveAvatar?.({
id: profile.profileId,
displayName: profile.displayName,
avatarMime: "image/png",
mergedInto: null,
createdAt: profile.updatedAt,
updatedAt: profile.updatedAt + 1,
});
await waitForFast(() => {
expect(harness.client).toMatchObject({
authenticatedUserProfile: { hasAvatar: true, updatedAt: profile.updatedAt + 1 },
});
});
});
});
it("falls back to email identity when durable profile resolution fails", async () => {
ensureProfileForEmailMock.mockImplementationOnce(() => {
throw new Error("profile store unavailable");
+2
View File
@@ -37,6 +37,8 @@ export type GatewayWsClient = PluginNodeCapabilityClient & {
sharedGatewaySessionGeneration?: string;
presenceKey?: string;
authenticatedUserId?: string;
/** Verified Tailscale provider identity; generic proxy identities must not infer this. */
authenticatedUserIsTailscaleProvider?: boolean;
authenticatedUserProfile?: {
profileId: string;
displayName: string | null;
+11
View File
@@ -20,4 +20,15 @@ CREATE TABLE IF NOT EXISTS user_profile_emails (
CREATE INDEX IF NOT EXISTS idx_user_profile_emails_profile_id
ON user_profile_emails(profile_id);
CREATE TABLE IF NOT EXISTS user_profile_identities (
provider TEXT NOT NULL,
subject TEXT NOT NULL,
profile_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (provider, subject)
) STRICT;
CREATE INDEX IF NOT EXISTS idx_user_profile_identities_profile_id
ON user_profile_identities(profile_id);
`;
@@ -0,0 +1,44 @@
import { fileTypeFromBuffer } from "file-type";
import { readRemoteMediaBuffer, type FetchLike } from "../media/fetch.js";
export const MAX_USER_PROFILE_AVATAR_BYTES = 512 * 1024;
export const USER_PROFILE_AVATAR_MIME_TYPES = ["image/png", "image/jpeg", "image/webp"] as const;
export type UserProfileAvatarMime = (typeof USER_PROFILE_AVATAR_MIME_TYPES)[number];
const TAILSCALE_AVATAR_FETCH_TIMEOUT_MS = 5_000;
const TAILSCALE_AVATAR_MAX_REDIRECTS = 3;
export type TailscaleAvatarFetchOptions = {
fetchImpl?: FetchLike;
timeoutMs?: number;
};
function toAvatarMime(value: string | undefined): UserProfileAvatarMime | null {
return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime)
? (value as UserProfileAvatarMime)
: null;
}
export async function fetchTailscaleAvatar(
url: string,
options: TailscaleAvatarFetchOptions,
): Promise<{ bytes: Buffer; mime: UserProfileAvatarMime } | null> {
try {
const timeoutMs = options.timeoutMs ?? TAILSCALE_AVATAR_FETCH_TIMEOUT_MS;
const loaded = await readRemoteMediaBuffer({
url,
fetchImpl: options.fetchImpl,
maxBytes: MAX_USER_PROFILE_AVATAR_BYTES,
maxRedirects: TAILSCALE_AVATAR_MAX_REDIRECTS,
timeoutMs,
responseHeaderTimeoutMs: timeoutMs,
readIdleTimeoutMs: timeoutMs,
requestInit: { headers: { Accept: USER_PROFILE_AVATAR_MIME_TYPES.join(",") } },
});
const mime = toAvatarMime(loaded.contentType);
const detected = await fileTypeFromBuffer(loaded.buffer);
return mime && detected?.mime === mime ? { bytes: loaded.buffer, mime } : null;
} catch {
return null;
}
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { classifyTailscaleLogin } from "./user-profiles-tailscale-login.js";
describe("Tailscale profile login classification", () => {
it.each([
["user@github", { kind: "provider", provider: "github", subject: "user" }],
["USER@PASSKEY", { kind: "provider", provider: "passkey", subject: "user" }],
["person@gmail.com", { kind: "email", email: "person@gmail.com" }],
["person@alias@gmail.com", { kind: "email", email: "person@alias@gmail.com" }],
["usér@github", { kind: "provider", provider: "github", subject: "usér" }],
["person@例え.テスト", { kind: "email", email: "person@例え.テスト" }],
["", { kind: "invalid" }],
["missing-at", { kind: "invalid" }],
["@github", { kind: "invalid" }],
["user@", { kind: "invalid" }],
])("classifies %j", (login, expected) => {
expect(classifyTailscaleLogin(login)).toEqual(expected);
});
});
@@ -0,0 +1,24 @@
type ClassifiedTailscaleLogin =
| { kind: "email"; email: string }
| { kind: "provider"; provider: string; subject: string }
| { kind: "invalid" };
export type TailscaleProfileIdentity = {
login: string;
name?: string;
profilePic?: string;
};
/** Classify Tailscale's documented email or email-ish LoginName representation. */
export function classifyTailscaleLogin(login: string): ClassifiedTailscaleLogin {
const normalized = login.trim();
const separator = normalized.lastIndexOf("@");
if (separator <= 0 || separator === normalized.length - 1) {
return { kind: "invalid" };
}
const subject = normalized.slice(0, separator);
const suffix = normalized.slice(separator + 1);
return suffix.includes(".")
? { kind: "email", email: normalized }
: { kind: "provider", provider: suffix.toLowerCase(), subject: subject.toLowerCase() };
}
@@ -0,0 +1,100 @@
// Doctor-only repair for Tailscale provider logins written as email aliases.
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../infra/kysely-sync.js";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
import {
openOpenClawStateDatabase,
runOpenClawStateWriteTransaction,
type OpenClawStateDatabaseOptions,
} from "./openclaw-state-db.js";
import { classifyTailscaleLogin } from "./user-profiles-tailscale-login.js";
import { ensureUserProfilesSchema, type UserProfilesDatabase } from "./user-profiles.js";
type UserProfileIdentityMigrationResult = {
changes: string[];
warnings: string[];
};
export function migrateLegacyTailscaleProfileIdentities(
options: OpenClawStateDatabaseOptions = {},
): UserProfileIdentityMigrationResult {
const database = openOpenClawStateDatabase(options);
if (!tableExists(database.db, "user_profile_emails")) {
return { changes: [], warnings: [] };
}
const kysely = getNodeSqliteKysely<UserProfilesDatabase>(database.db);
// Legacy aliases did not record auth provenance. Doctor intentionally applies
// the current LoginName classifier while preserving any conflicting alias.
const legacyRows = executeSqliteQuerySync(
database.db,
kysely
.selectFrom("user_profile_emails")
.select(["email", "profile_id", "created_at"])
.orderBy("email", "asc"),
).rows.flatMap((row) => {
const classified = classifyTailscaleLogin(row.email);
return classified.kind === "provider" ? [{ ...row, ...classified }] : [];
});
if (legacyRows.length === 0) {
return { changes: [], warnings: [] };
}
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const transactionKysely = getNodeSqliteKysely<UserProfilesDatabase>(db);
let migrated = 0;
const warnings: string[] = [];
for (const row of legacyRows) {
executeSqliteQuerySync(
db,
transactionKysely
.insertInto("user_profile_identities")
.values({
provider: row.provider,
subject: row.subject,
profile_id: row.profile_id,
created_at: row.created_at,
})
.onConflict((conflict) => conflict.columns(["provider", "subject"]).doNothing()),
);
const identity = executeSqliteQueryTakeFirstSync(
db,
transactionKysely
.selectFrom("user_profile_identities")
.select("profile_id")
.where("provider", "=", row.provider)
.where("subject", "=", row.subject),
);
if (identity?.profile_id !== row.profile_id) {
warnings.push(
`Kept legacy profile login ${row.email}: ${row.provider} identity is already linked to another profile.`,
);
continue;
}
executeSqliteQuerySync(
db,
transactionKysely
.deleteFrom("user_profile_emails")
.where("email", "=", row.email)
.where("profile_id", "=", row.profile_id),
);
migrated += 1;
}
return {
changes:
migrated > 0
? [
`Moved ${migrated} legacy Tailscale provider ${migrated === 1 ? "identity" : "identities"} out of user profile email aliases.`,
]
: [],
warnings,
};
},
options,
{ operationLabel: "user-profiles.migrate-legacy-identities" },
);
}
+271 -2
View File
@@ -1,14 +1,18 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, readFileSync } 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 { OPENCLAW_STATE_SCHEMA_VERSION } from "./openclaw-state-db.js";
import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "./openclaw-state-db.js";
import { migrateLegacyTailscaleProfileIdentities } from "./user-profiles-tailscale-migration.js";
import {
adoptTailscaleProfileAvatar,
ensureProfileForEmail,
ensureProfileForTailscaleIdentity,
formatUserProfileAvatarEtag,
getProfileAvatar,
linkEmail,
@@ -27,6 +31,25 @@ function stateOptions() {
return { path };
}
function fixtureImage(path: string): Buffer {
return readFileSync(join(process.cwd(), path));
}
function imageFetch(bytes: Uint8Array, mime: string) {
return vi.fn(
async () => new Response(Uint8Array.from(bytes).buffer, { headers: { "content-type": mime } }),
);
}
async function ensureTailscaleProfileWithAvatar(
identity: Parameters<typeof ensureProfileForTailscaleIdentity>[0],
options: Parameters<typeof ensureProfileForTailscaleIdentity>[1],
fetchOptions: Parameters<typeof adoptTailscaleProfileAvatar>[3],
) {
const profile = ensureProfileForTailscaleIdentity(identity, options);
return await adoptTailscaleProfileAvatar(profile.id, identity.profilePic, options, fetchOptions);
}
afterEach(() => {
vi.restoreAllMocks();
closeOpenClawStateDatabaseForTest();
@@ -35,12 +58,22 @@ afterEach(() => {
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 database = openOpenClawStateDatabase(options).db;
const versionBefore = database.prepare("PRAGMA user_version").get()?.user_version;
expect(tableExists(database, "user_profiles")).toBe(false);
expect(tableExists(database, "user_profile_identities")).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(tableExists(openOpenClawStateDatabase(options).db, "user_profile_identities")).toBe(
true,
);
expect(
openOpenClawStateDatabase(options).db.prepare("PRAGMA user_version").get()?.user_version,
).toBe(versionBefore);
expect(OPENCLAW_STATE_SCHEMA_VERSION).toBe(6);
expect(second).toEqual(first);
expect(ensureProfileForEmail("ADA@example.com", options)).toEqual(first);
expect(listProfiles(options)).toEqual([
@@ -48,6 +81,65 @@ describe("user profiles", () => {
]);
});
it("resolves provider identities without storing them as emails", () => {
const options = stateOptions();
const first = ensureProfileForTailscaleIdentity(
{ login: "Ada@GitHub", name: "Ada Lovelace" },
options,
);
const second = ensureProfileForTailscaleIdentity(
{ login: "ada@github", name: "Different Provider Name" },
options,
);
expect(second.id).toBe(first.id);
expect(second.displayName).toBe("Ada Lovelace");
expect(listProfiles(options)).toEqual([
expect.objectContaining({ id: first.id, emails: [], displayName: "Ada Lovelace" }),
]);
expect(
openOpenClawStateDatabase(options)
.db.prepare(
"SELECT provider, subject, profile_id FROM user_profile_identities ORDER BY provider, subject",
)
.all(),
).toEqual([{ provider: "github", subject: "ada", profile_id: first.id }]);
});
it("keeps dotted Tailscale logins on the email alias path", () => {
const options = stateOptions();
const profile = ensureProfileForTailscaleIdentity(
{ login: "Person@Gmail.COM", name: "Person Example" },
options,
);
expect(ensureProfileForEmail("person@gmail.com", options).id).toBe(profile.id);
expect(profile.displayName).toBe("Person Example");
expect(listProfiles(options)).toEqual([
expect.objectContaining({ id: profile.id, emails: ["person@gmail.com"] }),
]);
});
it("adopts a Tailscale name only while the display-name slot is empty", () => {
const options = stateOptions();
const profile = ensureProfileForTailscaleIdentity(
{ login: "ada@github", name: "Ada Provider" },
options,
);
setDisplayName(profile.id, null, options);
expect(
ensureProfileForTailscaleIdentity({ login: "ada@github", name: "Ada Adopted" }, options),
).toMatchObject({ displayName: "Ada Adopted" });
setDisplayName(profile.id, "User Chosen", options);
expect(
ensureProfileForTailscaleIdentity({ login: "ada@github", name: "Provider Changed" }, options),
).toMatchObject({ displayName: "User Chosen" });
});
it("moves aliases and leaves an aliasless source profile as a one-hop tombstone", () => {
const options = stateOptions();
const source = ensureProfileForEmail("source@example.com", options);
@@ -149,6 +241,183 @@ describe("user profiles", () => {
expect(profile.displayName).toHaveLength(256);
});
it.each([
["image/png", "ui/public/favicon-32.png"],
["image/jpeg", "docs/whatsapp-openclaw.jpg"],
["image/webp", "ui/public/app-art/android.webp"],
])("adopts a bounded %s Tailscale avatar", async (mime, path) => {
const options = stateOptions();
const bytes = fixtureImage(path);
const profile = await ensureTailscaleProfileWithAvatar(
{
login: `avatar-${mime.slice("image/".length)}@github`,
name: "Avatar User",
profilePic: "https://avatars.example.test/profile",
},
options,
{ fetchImpl: imageFetch(bytes, mime) },
);
expect(profile.avatarMime).toBe(mime);
const stored = getProfileAvatar(profile.id, options);
expect(stored).toMatchObject({
mime,
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
});
expect(Buffer.from(stored?.bytes ?? []).equals(bytes)).toBe(true);
});
it.each([
{
name: "oversized",
fetchImpl: vi.fn(
async () =>
new Response("x", {
headers: {
"content-length": String(512 * 1024 + 1),
"content-type": "image/png",
},
}),
),
},
{
name: "wrong-type",
fetchImpl: vi.fn(
async () => new Response("not an image", { headers: { "content-type": "text/plain" } }),
),
},
{
name: "failed-fetch",
fetchImpl: vi.fn(async () => {
throw new Error("network unavailable");
}),
},
])("keeps the avatar empty after a $name fetch", async ({ fetchImpl }) => {
const options = stateOptions();
const profile = await ensureTailscaleProfileWithAvatar(
{
login: "avatar-failure@github",
name: "Still Authenticated",
profilePic: "https://avatars.example.test/profile",
},
options,
{ fetchImpl },
);
expect(profile).toMatchObject({ displayName: "Still Authenticated", avatarMime: null });
expect(getProfileAvatar(profile.id, options)).toBeUndefined();
});
it("times out avatar adoption without failing profile resolution", async () => {
const options = stateOptions();
const fetchImpl = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) =>
await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener(
"abort",
() =>
reject(
init.signal?.reason instanceof Error
? init.signal.reason
: new Error("avatar fetch aborted"),
),
{ once: true },
);
}),
);
const profile = await ensureTailscaleProfileWithAvatar(
{
login: "avatar-timeout@github",
name: "Timeout User",
profilePic: "https://avatars.example.test/profile",
},
options,
{ fetchImpl, timeoutMs: 10 },
);
expect(profile).toMatchObject({ displayName: "Timeout User", avatarMime: null });
expect(getProfileAvatar(profile.id, options)).toBeUndefined();
});
it("preserves a user avatar written while provider avatar bytes are in flight", async () => {
const options = stateOptions();
let resolveFetch: ((response: Response) => void) | undefined;
const fetchImpl = vi.fn(
async () =>
await new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
);
const pending = ensureTailscaleProfileWithAvatar(
{
login: "avatar-race@github",
name: "Race User",
profilePic: "https://avatars.example.test/profile",
},
options,
{ fetchImpl },
);
await vi.waitFor(() => expect(resolveFetch).toBeTypeOf("function"));
const profileId = listProfiles(options)[0]?.id;
expect(profileId).toBeTruthy();
expect(setAvatar(profileId!, new Uint8Array([9, 8, 7]), "image/png", options).ok).toBe(true);
resolveFetch?.(
new Response(Uint8Array.from(fixtureImage("ui/public/favicon-32.png")).buffer, {
headers: { "content-type": "image/png" },
}),
);
await pending;
expect(getProfileAvatar(profileId!, options)?.bytes).toEqual(new Uint8Array([9, 8, 7]));
});
it("migrates legacy provider logins while preserving profiles and real emails", () => {
const options = stateOptions();
const provider = ensureProfileForEmail("user@github", options);
const email = ensureProfileForEmail("person@gmail.com", options);
setDisplayName(provider.id, "User Chosen", options);
expect(setAvatar(provider.id, new Uint8Array([9, 8, 7]), "image/png", options).ok).toBe(true);
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({
changes: ["Moved 1 legacy Tailscale provider identity out of user profile email aliases."],
warnings: [],
});
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({ changes: [], warnings: [] });
const database = openOpenClawStateDatabase(options).db;
expect(
database.prepare("SELECT provider, subject, profile_id FROM user_profile_identities").all(),
).toEqual([{ provider: "github", subject: "user", profile_id: provider.id }]);
expect(database.prepare("SELECT email, profile_id FROM user_profile_emails").all()).toEqual([
{ email: "person@gmail.com", profile_id: email.id },
]);
expect(listProfiles(options)).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: provider.id,
displayName: "User Chosen",
emails: [],
hasAvatar: true,
}),
expect.objectContaining({ id: email.id, emails: ["person@gmail.com"] }),
]),
);
expect(getProfileAvatar(provider.id, options)?.bytes).toEqual(new Uint8Array([9, 8, 7]));
});
it("does not activate user-profile tables when Doctor has no legacy aliases", () => {
const options = stateOptions();
const database = openOpenClawStateDatabase(options).db;
expect(migrateLegacyTailscaleProfileIdentities(options)).toEqual({ changes: [], warnings: [] });
expect(tableExists(database, "user_profiles")).toBe(false);
expect(tableExists(database, "user_profile_identities")).toBe(false);
});
it("rejects oversized and unsupported avatar uploads", () => {
const options = stateOptions();
const profile = ensureProfileForEmail("ada@example.com", options);
+207 -15
View File
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
// Durable user profiles and mutable login-email aliases in the shared state DB.
// Durable user profiles plus typed login identities 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";
@@ -16,11 +16,17 @@ import {
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];
import {
fetchTailscaleAvatar,
MAX_USER_PROFILE_AVATAR_BYTES,
USER_PROFILE_AVATAR_MIME_TYPES,
type TailscaleAvatarFetchOptions,
type UserProfileAvatarMime,
} from "./user-profiles-tailscale-avatar.js";
import {
classifyTailscaleLogin,
type TailscaleProfileIdentity,
} from "./user-profiles-tailscale-login.js";
type UserProfile = {
id: string;
@@ -58,7 +64,7 @@ export class UserProfileNotFoundError extends Error {
}
}
type UserProfilesDatabase = {
export type UserProfilesDatabase = {
user_profiles: {
id: string;
display_name: string | null;
@@ -74,6 +80,12 @@ type UserProfilesDatabase = {
profile_id: string;
created_at: number;
};
user_profile_identities: {
provider: string;
subject: string;
profile_id: string;
created_at: number;
};
};
type UserProfileRow = UserProfilesDatabase["user_profiles"];
@@ -91,7 +103,7 @@ function profileDb(db: DatabaseSync) {
return getNodeSqliteKysely<UserProfilesDatabase>(db);
}
function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void {
export function ensureUserProfilesSchema(options: OpenClawStateDatabaseOptions): void {
const database = openOpenClawStateDatabase(options);
if (ensuredDatabases.has(database.db)) {
return;
@@ -116,6 +128,11 @@ function normalizeEmail(email: string): string {
return normalized;
}
function normalizeInitialDisplayName(name: string | undefined): string | null {
const normalized = name?.trim();
return normalized ? normalized.slice(0, MAX_USER_PROFILE_DISPLAY_NAME_LENGTH) : null;
}
function toAvatarMime(value: string | null): UserProfileAvatarMime | null {
return USER_PROFILE_AVATAR_MIME_TYPES.includes(value as UserProfileAvatarMime)
? (value as UserProfileAvatarMime)
@@ -232,18 +249,20 @@ export function getUserProfileListItem(
return selectUserProfileListItemById(db, requireResolvedProfileById(db, profileId).id);
}
/** Resolves an email alias or atomically creates its first durable profile. */
export function ensureProfileForEmail(
function ensureProfileForEmailWithInitialName(
email: string,
options: OpenClawStateDatabaseOptions = {},
initialDisplayName: string | null,
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,
);
const displayName =
initialDisplayName ??
(normalizedEmail.split("@", 1)[0] || normalizedEmail).slice(
0,
MAX_USER_PROFILE_DISPLAY_NAME_LENGTH,
);
ensureUserProfilesSchema(options);
return runOpenClawStateWriteTransaction(
({ db }) => {
@@ -284,6 +303,179 @@ export function ensureProfileForEmail(
);
}
/** Resolves an email alias or atomically creates its first durable profile. */
export function ensureProfileForEmail(
email: string,
options: OpenClawStateDatabaseOptions = {},
): UserProfile {
return ensureProfileForEmailWithInitialName(email, null, options);
}
function ensureProfileForProviderIdentity(params: {
provider: string;
subject: string;
initialDisplayName: string | null;
options: OpenClawStateDatabaseOptions;
}): UserProfile {
const profileId = generateSecureUuid();
const now = Date.now();
ensureUserProfilesSchema(params.options);
return runOpenClawStateWriteTransaction(
({ db }) => {
const kysely = profileDb(db);
const existingIdentity = executeSqliteQueryTakeFirstSync(
db,
kysely
.selectFrom("user_profile_identities")
.select("profile_id")
.where("provider", "=", params.provider)
.where("subject", "=", params.subject),
);
if (existingIdentity) {
return toUserProfile(requireResolvedProfileById(db, existingIdentity.profile_id));
}
const row: UserProfileRow = {
id: profileId,
display_name: params.initialDisplayName,
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_identities").values({
provider: params.provider,
subject: params.subject,
profile_id: profileId,
created_at: now,
}),
);
return toUserProfile(row);
},
params.options,
{ operationLabel: "user-profiles.ensure-identity" },
);
}
function adoptDisplayNameIfEmpty(
profileId: string,
displayName: string | null,
options: OpenClawStateDatabaseOptions,
): UserProfile {
if (!displayName) {
const { db } = openOpenClawStateDatabase(options);
return toUserProfile(requireResolvedProfileById(db, profileId));
}
const now = Date.now();
return runOpenClawStateWriteTransaction(
({ db }) => {
const profile = requireResolvedProfileById(db, profileId);
if (profile.display_name !== null) {
return toUserProfile(profile);
}
executeSqliteQuerySync(
db,
profileDb(db)
.updateTable("user_profiles")
.set({ display_name: displayName, updated_at: now })
.where("id", "=", profile.id),
);
return toUserProfile({ ...profile, display_name: displayName, updated_at: now });
},
options,
{ operationLabel: "user-profiles.adopt-display-name" },
);
}
async function adoptAvatarIfEmpty(params: {
profileId: string;
profilePic: string | undefined;
options: OpenClawStateDatabaseOptions;
fetchOptions: TailscaleAvatarFetchOptions;
}): Promise<UserProfile> {
const { db } = openOpenClawStateDatabase(params.options);
const beforeFetch = requireResolvedProfileById(db, params.profileId);
if (beforeFetch.avatar !== null || !params.profilePic) {
return toUserProfile(beforeFetch);
}
const avatar = await fetchTailscaleAvatar(params.profilePic, params.fetchOptions);
if (!avatar) {
return toUserProfile(requireResolvedProfileById(db, params.profileId));
}
const now = Date.now();
return runOpenClawStateWriteTransaction(
({ db: transactionDb }) => {
const profile = requireResolvedProfileById(transactionDb, params.profileId);
if (profile.avatar !== null) {
return toUserProfile(profile);
}
const sha256 = createHash("sha256").update(avatar.bytes).digest("hex");
executeSqliteQuerySync(
transactionDb,
profileDb(transactionDb)
.updateTable("user_profiles")
.set({
avatar: avatar.bytes,
avatar_mime: avatar.mime,
avatar_sha256: sha256,
updated_at: now,
})
.where("id", "=", profile.id),
);
return toUserProfile({
...profile,
avatar: avatar.bytes,
avatar_mime: avatar.mime,
avatar_sha256: sha256,
updated_at: now,
});
},
params.options,
{ operationLabel: "user-profiles.adopt-avatar" },
);
}
/** Resolves a verified Tailscale login and adopts its display name into an empty field. */
export function ensureProfileForTailscaleIdentity(
identity: TailscaleProfileIdentity,
options: OpenClawStateDatabaseOptions = {},
): UserProfile {
const classified = classifyTailscaleLogin(identity.login);
if (classified.kind === "invalid") {
throw new TypeError("Tailscale login must contain a nonempty subject and suffix");
}
const displayName = normalizeInitialDisplayName(identity.name);
const resolved =
classified.kind === "email"
? ensureProfileForEmailWithInitialName(classified.email, displayName, options)
: ensureProfileForProviderIdentity({
provider: classified.provider,
subject: classified.subject,
initialDisplayName: displayName,
options,
});
return adoptDisplayNameIfEmpty(resolved.id, displayName, options);
}
/** Best-effort avatar adoption runs after authentication so remote I/O cannot delay login. */
export async function adoptTailscaleProfileAvatar(
profileId: string,
profilePic: string | undefined,
options: OpenClawStateDatabaseOptions = {},
fetchOptions: TailscaleAvatarFetchOptions = {},
): Promise<UserProfile> {
return await adoptAvatarIfEmpty({
profileId,
profilePic,
options,
fetchOptions,
});
}
/** Links an email to a profile and retains an aliasless prior profile as a merge tombstone. */
export function linkEmail(
email: string,