From dca3ed16debbb2abbe15cdde564ecbf53665525b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 4 Aug 2026 23:09:06 -0700 Subject: [PATCH] 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 --- src/commands/doctor/repair-sequencing.test.ts | 25 ++ src/commands/doctor/repair-sequencing.ts | 2 + src/gateway/auth.test.ts | 20 +- src/gateway/auth.ts | 9 +- src/gateway/server-methods/shared-types.ts | 2 + src/gateway/server-methods/users.test.ts | 77 +++++ src/gateway/server-methods/users.ts | 39 ++- .../server/ws-connection/connect-session.ts | 97 +++++-- ...essage-handler.post-connect-health.test.ts | 100 ++++++- src/gateway/server/ws-types.ts | 2 + src/state/user-profiles-schema.ts | 11 + src/state/user-profiles-tailscale-avatar.ts | 44 +++ .../user-profiles-tailscale-login.test.ts | 19 ++ src/state/user-profiles-tailscale-login.ts | 24 ++ .../user-profiles-tailscale-migration.ts | 100 +++++++ src/state/user-profiles.test.ts | 273 +++++++++++++++++- src/state/user-profiles.ts | 222 +++++++++++++- 17 files changed, 1009 insertions(+), 57 deletions(-) create mode 100644 src/state/user-profiles-tailscale-avatar.ts create mode 100644 src/state/user-profiles-tailscale-login.test.ts create mode 100644 src/state/user-profiles-tailscale-login.ts create mode 100644 src/state/user-profiles-tailscale-migration.ts diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 32f5dadc9919..a6a243006081 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -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; diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 8f3d1d668408..3f3fc82aa82a 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -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({ diff --git a/src/gateway/auth.test.ts b/src/gateway/auth.test.ts index 686cba90e332..31f9b4cf1751 100644 --- a/src/gateway/auth.test.ts +++ b/src/gateway/auth.test.ts @@ -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" }, }); }); diff --git a/src/gateway/auth.ts b/src/gateway/auth.ts index c604c8c2edef..2ead23fda970 100644 --- a/src/gateway/auth.ts +++ b/src/gateway/auth.ts @@ -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, }; } } diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index 5a4d48a4b0eb..393066d199d5 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -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; diff --git a/src/gateway/server-methods/users.test.ts b/src/gateway/server-methods/users.test.ts index 59840ee95795..b3f1c680df32 100644 --- a/src/gateway/server-methods/users.test.ts +++ b/src/gateway/server-methods/users.test.ts @@ -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"); diff --git a/src/gateway/server-methods/users.ts b/src/gateway/server-methods/users.ts index 03043bc2e7fd..e9f316226d36 100644 --- a/src/gateway/server-methods/users.ts +++ b/src/gateway/server-methods/users.ts @@ -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)); } diff --git a/src/gateway/server/ws-connection/connect-session.ts b/src/gateway/server/ws-connection/connect-session.ts index 6b6ae51410fb..b7a3721d6d04 100644 --- a/src/gateway/server/ws-connection/connect-session.ts +++ b/src/gateway/server/ws-connection/connect-session.ts @@ -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= revision changes when the profile (avatar) is - // updated, so a reconnecting viewer's 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)}`), + ); + } } diff --git a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts index d062a793badb..195aed1b9e0b 100644 --- a/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts +++ b/src/gateway/server/ws-connection/message-handler.post-connect-health.test.ts @@ -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(); + 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(); + 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"); diff --git a/src/gateway/server/ws-types.ts b/src/gateway/server/ws-types.ts index e0d63b3dbf5a..efaa002fe37d 100644 --- a/src/gateway/server/ws-types.ts +++ b/src/gateway/server/ws-types.ts @@ -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; diff --git a/src/state/user-profiles-schema.ts b/src/state/user-profiles-schema.ts index e84fb0e8c289..47c78798390b 100644 --- a/src/state/user-profiles-schema.ts +++ b/src/state/user-profiles-schema.ts @@ -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); `; diff --git a/src/state/user-profiles-tailscale-avatar.ts b/src/state/user-profiles-tailscale-avatar.ts new file mode 100644 index 000000000000..19371e4a58ca --- /dev/null +++ b/src/state/user-profiles-tailscale-avatar.ts @@ -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; + } +} diff --git a/src/state/user-profiles-tailscale-login.test.ts b/src/state/user-profiles-tailscale-login.test.ts new file mode 100644 index 000000000000..501599fba4eb --- /dev/null +++ b/src/state/user-profiles-tailscale-login.test.ts @@ -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); + }); +}); diff --git a/src/state/user-profiles-tailscale-login.ts b/src/state/user-profiles-tailscale-login.ts new file mode 100644 index 000000000000..304c1b04323d --- /dev/null +++ b/src/state/user-profiles-tailscale-login.ts @@ -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() }; +} diff --git a/src/state/user-profiles-tailscale-migration.ts b/src/state/user-profiles-tailscale-migration.ts new file mode 100644 index 000000000000..ee87f9b73931 --- /dev/null +++ b/src/state/user-profiles-tailscale-migration.ts @@ -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(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(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" }, + ); +} diff --git a/src/state/user-profiles.test.ts b/src/state/user-profiles.test.ts index 7a49543b554e..ec0869e7a0df 100644 --- a/src/state/user-profiles.test.ts +++ b/src/state/user-profiles.test.ts @@ -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[0], + options: Parameters[1], + fetchOptions: Parameters[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((_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((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); diff --git a/src/state/user-profiles.ts b/src/state/user-profiles.ts index 2f55881bc6dc..8895a6b2cf16 100644 --- a/src/state/user-profiles.ts +++ b/src/state/user-profiles.ts @@ -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(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 { + 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 { + 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,